#![allow(clippy::wildcard_imports)]
use super::*;
#[cfg(feature = "cloud")]
pub(crate) const QWEN_WS_URL_QWENCLOUD: &str = "wss://maas.qwencloudapi.com/api-ws/v1/inference";
#[cfg(feature = "cloud")]
pub(crate) const QWEN_WS_URL_INTL: &str = "wss://dashscope-intl.aliyuncs.com/api-ws/v1/inference";
#[cfg(feature = "cloud")]
pub(crate) const QWEN_WS_URL_BEIJING: &str = "wss://dashscope.aliyuncs.com/api-ws/v1/inference";
#[cfg(feature = "cloud")]
#[cfg(feature = "cloud")]
#[cfg(feature = "cloud")]
const QWEN_TEXT_CHUNK_CHARS: usize = 19_000;
#[cfg(feature = "cloud")]
pub(crate) fn qwen_ws_url(credentials: &HashMap<String, String>) -> TtsResult<String> {
if let Some(url) = credentials.get("wsUrl").filter(|u| !u.is_empty()) {
return Ok(url.clone());
}
match credentials.get("region").map(String::as_str) {
None | Some("qwencloud" | "") => Ok(QWEN_WS_URL_QWENCLOUD.into()),
Some("intl") => Ok(QWEN_WS_URL_INTL.into()),
Some("beijing") => Ok(QWEN_WS_URL_BEIJING.into()),
Some(other) => Err(TtsError(format!(
"qwen: unknown region '{other}' (expected qwencloud, intl, or beijing; \
or set wsUrl to override the endpoint)"
))),
}
}
#[cfg(feature = "cloud")]
#[derive(Debug, PartialEq)]
pub(crate) enum QwenServerEvent {
TaskStarted,
AudioFrame { sentence: u64 },
SentenceEnd { sentence: u64, words: Vec<QwenWord> },
TaskFinished,
TaskFailed { code: String, message: String },
Other,
}
#[cfg(feature = "cloud")]
#[derive(Debug, PartialEq)]
pub(crate) struct QwenWord {
pub(crate) text: String,
pub(crate) begin_ms: u64,
pub(crate) end_ms: u64,
}
#[cfg(feature = "cloud")]
#[derive(Debug, Default)]
pub(crate) struct QwenSentenceClock {
bytes_by_sentence: std::collections::HashMap<u64, usize>,
}
#[cfg(feature = "cloud")]
impl QwenSentenceClock {
pub(crate) fn audio_frame(&mut self, sentence: u64, len: usize) {
*self.bytes_by_sentence.entry(sentence).or_insert(0) += len;
}
#[allow(clippy::cast_possible_truncation)]
pub(crate) fn sentence_base_ms(&self, sentence: u64) -> u64 {
let bytes: usize = self
.bytes_by_sentence
.iter()
.filter(|(&idx, _)| idx < sentence)
.map(|(_, &v)| v)
.sum();
(bytes / 48) as u64
}
}
#[cfg(feature = "cloud")]
pub(crate) fn qwen_parse_event(frame: &str) -> QwenServerEvent {
let Ok(json) = serde_json::from_str::<serde_json::Value>(frame) else {
return QwenServerEvent::Other;
};
let header = json.get("header");
let event = header
.and_then(|h| h.get("event"))
.and_then(|v| v.as_str())
.unwrap_or_default();
match event {
"task-started" => QwenServerEvent::TaskStarted,
"task-finished" => QwenServerEvent::TaskFinished,
"task-failed" => QwenServerEvent::TaskFailed {
code: header
.and_then(|h| h.get("error_code"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
message: header
.and_then(|h| h.get("error_message"))
.and_then(|v| v.as_str())
.unwrap_or("qwen synthesis failed")
.to_string(),
},
"result-generated" => {
let output = json.get("payload").and_then(|p| p.get("output"));
let ty = output
.and_then(|o| o.get("type"))
.and_then(|v| v.as_str())
.unwrap_or_default();
let sentence_index = || {
output
.and_then(|o| o.get("sentence"))
.and_then(|s| s.get("index"))
.and_then(serde_json::Value::as_u64)
.unwrap_or_default()
};
match ty {
"sentence-synthesis" => QwenServerEvent::AudioFrame {
sentence: sentence_index(),
},
"sentence-end" => {
let words = output
.and_then(|o| o.get("sentence"))
.and_then(|s| s.get("words"))
.and_then(|w| w.as_array())
.map(|arr| {
arr.iter()
.filter_map(|w| {
let text = w.get("text").and_then(|v| v.as_str())?.to_string();
Some(QwenWord {
text,
begin_ms: w
.get("begin_time")
.and_then(serde_json::Value::as_u64)
.unwrap_or_default(),
end_ms: w
.get("end_time")
.and_then(serde_json::Value::as_u64)
.unwrap_or_default(),
})
})
.collect()
})
.unwrap_or_default();
QwenServerEvent::SentenceEnd {
sentence: sentence_index(),
words,
}
}
_ => QwenServerEvent::Other,
}
}
_ => QwenServerEvent::Other,
}
}
#[cfg(feature = "cloud")]
#[allow(clippy::too_many_arguments)]
pub(crate) fn qwen_run_task_json(
task_id: &str,
model: &str,
voice: &str,
rate: f32,
pitch: f32,
volume: f32,
word_timestamps: bool,
instruction: Option<&str>,
) -> serde_json::Value {
#[allow(clippy::cast_possible_truncation)]
let volume_int = (volume.clamp(0.0, 2.0) * 50.0).round() as i64;
let mut parameters = serde_json::json!({
"text_type": "PlainText",
"voice": voice,
"format": "pcm",
"sample_rate": 24_000,
"volume": volume_int,
"rate": rate.clamp(0.5, 2.0),
"pitch": pitch.clamp(0.5, 2.0),
"word_timestamp_enabled": word_timestamps,
});
if let Some(instr) = instruction.filter(|s| !s.is_empty()) {
parameters["instruction"] = serde_json::Value::String(instr.to_string());
}
serde_json::json!({
"header": {
"action": "run-task",
"task_id": task_id,
"streaming": "duplex",
},
"payload": {
"task_group": "audio",
"task": "tts",
"function": "SpeechSynthesizer",
"model": model,
"parameters": parameters,
"input": {},
},
})
}
#[cfg(feature = "cloud")]
pub(crate) fn qwen_chunk_text(text: &str) -> Vec<&str> {
if text.chars().count() <= QWEN_TEXT_CHUNK_CHARS {
return vec![text];
}
let mut chunks = Vec::new();
let mut start = 0;
while start < text.len() {
let mut end = text[start..]
.char_indices()
.nth(QWEN_TEXT_CHUNK_CHARS)
.map_or(text.len(), |(i, _)| start + i);
end = end.clamp(start + 1, text.len());
chunks.push(&text[start..end]);
start = end;
}
chunks
}
#[cfg(feature = "cloud")]
pub(crate) type QwenAudioFn<'a> = &'a mut dyn FnMut(&[u8]);
#[cfg(feature = "cloud")]
pub(crate) type QwenBoundaryFn<'a> = &'a mut dyn FnMut(&str, f32, f32, i32, i32, bool);
#[cfg(feature = "cloud")]
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
#[allow(clippy::cast_precision_loss)]
pub(crate) fn qwen_speak_ws(
text: &str,
voice: &str,
rate: f32,
pitch: f32,
volume: f32,
api_key: &str,
credentials: &HashMap<String, String>,
model: &str,
instruction: Option<&str>,
mut on_audio: Option<QwenAudioFn<'_>>,
mut on_boundary: Option<QwenBoundaryFn<'_>>,
boundary_search_text: &str,
) -> TtsResult<usize> {
use tungstenite::client::IntoClientRequest;
use tungstenite::{connect, Message};
use url::Url;
if text.is_empty() {
return Err(TtsError("qwen: refusing to synthesize empty text".into()));
}
let ws_url_str = qwen_ws_url(credentials)?;
let ws_url = Url::parse(&ws_url_str).map_err(|e| TtsError(format!("Invalid WS URL: {e}")))?;
let mut req = ws_url
.as_str()
.into_client_request()
.map_err(|e| TtsError(format!("WS request build: {e}")))?;
{
let h = req.headers_mut();
let auth = format!("bearer {api_key}")
.parse()
.map_err(|e| TtsError(format!("Authorization header: {e}")))?;
h.insert("Authorization", auth);
let ua = "rust-tts-wrapper"
.parse()
.map_err(|e| TtsError(format!("User-Agent header: {e}")))?;
h.insert("User-Agent", ua);
}
let mut socket = connect(req)
.map_err(|e| TtsError(format!("WS connect error: {e}")))?
.0;
let task_id = Uuid::new_v4().simple().to_string();
let send = |socket: &mut tungstenite::WebSocket<
tungstenite::stream::MaybeTlsStream<std::net::TcpStream>,
>,
value: &serde_json::Value,
what: &str|
-> TtsResult<()> {
socket
.send(Message::Text(value.to_string().into()))
.map_err(|e| TtsError(format!("WS {what} send error: {e}")))
};
let run_task = qwen_run_task_json(
&task_id,
model,
voice,
rate,
pitch,
volume,
on_boundary.is_some(),
instruction,
);
send(&mut socket, &run_task, "run-task")?;
#[allow(clippy::duration_suboptimal_units)]
let idle_limit = || std::time::Duration::from_secs(120);
let mut deadline = std::time::Instant::now() + idle_limit();
loop {
if std::time::Instant::now() > deadline {
let _ = socket.close(None);
return Err(TtsError("qwen WebSocket task start timed out".into()));
}
match socket.read() {
Ok(Message::Text(t)) => match qwen_parse_event(t.as_str()) {
QwenServerEvent::TaskStarted => break,
QwenServerEvent::TaskFailed { code, message } => {
let _ = socket.close(None);
return Err(TtsError(format!("qwen task failed: {message} ({code})")));
}
_ => {}
},
Ok(_) => {}
Err(e) => return Err(TtsError(format!("WS receive error: {e}"))),
}
deadline = std::time::Instant::now() + idle_limit();
}
for chunk in qwen_chunk_text(text) {
let continue_task = serde_json::json!({
"header": {
"action": "continue-task",
"task_id": task_id,
"streaming": "duplex",
},
"payload": { "input": { "text": chunk } },
});
send(&mut socket, &continue_task, "continue-task")?;
}
let finish_task = serde_json::json!({
"header": {
"action": "finish-task",
"task_id": task_id,
"streaming": "duplex",
},
"payload": { "input": {} },
});
send(&mut socket, &finish_task, "finish-task")?;
let mut search = crate::word_search::WordSearch::new(boundary_search_text);
let mut clock = QwenSentenceClock::default();
let mut audio_bytes = 0usize;
let mut pending_frame_sentence: u64 = 0;
loop {
if std::time::Instant::now() > deadline {
let _ = socket.close(None);
return Err(TtsError("qwen WebSocket synthesis timed out".into()));
}
let msg = match socket.read() {
Ok(m) => m,
Err(
tungstenite::error::Error::ConnectionClosed
| tungstenite::error::Error::AlreadyClosed,
) => break,
Err(e) => return Err(TtsError(format!("WS receive error: {e}"))),
};
deadline = std::time::Instant::now() + idle_limit();
match msg {
Message::Text(t) => match qwen_parse_event(t.as_str()) {
QwenServerEvent::TaskFinished => {
let _ = socket.close(None);
return Ok(audio_bytes);
}
QwenServerEvent::TaskFailed { code, message } => {
let _ = socket.close(None);
return Err(TtsError(format!("qwen task failed: {message} ({code})")));
}
QwenServerEvent::AudioFrame { sentence } => {
pending_frame_sentence = sentence;
}
QwenServerEvent::SentenceEnd { sentence, words } => {
let sentence_start_ms = clock.sentence_base_ms(sentence);
if let Some(cb) = on_boundary.as_mut() {
for w in &words {
let (char_offset, char_len) = search.find_next(&w.text);
cb(
&w.text,
(sentence_start_ms + w.begin_ms) as f32 / 1000.0,
(sentence_start_ms + w.end_ms) as f32 / 1000.0,
char_offset.max(0),
char_len,
false,
);
}
}
}
_ => {}
},
Message::Binary(b) if !b.is_empty() => {
audio_bytes += b.len();
clock.audio_frame(pending_frame_sentence, b.len());
if let Some(cb) = on_audio.as_mut() {
cb(&b);
}
}
_ => {}
}
}
Err(TtsError(format!(
"qwen connection closed before task-finished ({audio_bytes} audio bytes delivered)"
)))
}