超越 llamadart 聊天 · 第 3 部分
Pip 的围巾不见了。我希望 Dash 能飞到 Mo 那里,大声问起这件事。
Dash:“Mo,你看到 Pip 的红围巾了吗?”
Mo:“看到了,就在池塘边的芦苇丛里。”
我用本地模型试了这段对话。目前,它运行在一个小型命令行程序里,位于Llama Village之外。问题是一段合成录音,Mo 在他的提示词中得到了围巾的位置。游戏目前还没有语音控制。
现有的村庄。我想让玩家能和这些角色说话。
Mo 倾听的两种方式
ASR 是语音识别。TTS 是文本转语音。两条路线最终都以 TTS 收尾。概念示意图。
经由文本:Qwen3-ASR 转写录音,对话模型作答,再由 Qwen3-TTS 朗读。当“Pip”被听错时,有转写文本会有帮助。
直接音频:Qwen2.5-Omni 接收录音并写出回答。这个示例没有单独的转写文本。GGUF 转换接受音频但不生成音频,所以 Mo 的回复我仍然使用 TTS。
为了对比,两条路线使用同一个 3B 对话模型。在第一条路线中它接收文本;在第二条路线中它还会加载自己的音频投影器。这是一条经过测试的多模态路线,并不代表所有多模态模型都需要单独的 TTS。
我还为文本路线试了一对更小的模型:Qwen3.5 0.8B用于对话,Qwen3-TTS 0.6B Base用于语音。Qwen3-ASR 0.6B 仍然负责识别。下面默认使用这对更小的模型;staged保留原来的 3B/1.7B 组合用于对比。这里 Qwen3.5 用于文本,而非音频输入。
在你的 Mac 上试试
完整示例如下。你需要macOS、Dart 3.10.7 或更新版本,以及约 2.31 GB 空间用于五个小模型文件,外加 Dart 包和原生运行时的空间。我在 M4 Max 上测试,使用 Dart 3.13.5 和llamadart 0.11.0。这些命令使用 macOSsay、afconvert和afplay;它们不是手机或浏览器演示。
1. 创建项目
在终端中运行。这个 Dart 程序使用固定版本的ModelSource.huggingFace引用。每个引擎的load()使用 llamadart 的下载管理器来获取缺失文件并复用其缓存。没有单独的 curl 脚本。首次运行需要网络连接,耗时更长;后续生成则在本地使用已下载的模型。
mkdir -p llama-voice-demo/bin
cdllama-voice-demo
cat > pubspec.yaml <<'YAML'
name: llama_voice_demo
publish_to: none
environment:
sdk: ^3.10.7
dependencies:
llamadart: 0.11.0YAMLdart pub get
2. 添加完整程序
留在llama-voice-demo并粘贴此代码块。它会创建bin/voice.dart。程序一次加载一个引擎,并在加载下一个之前释放它。
cat > bin/voice.dart <<'DART'
import 'dart:io';
import 'package:llamadart/llamadart.dart';
const params = ModelParams(
contextSize: 4096,
numberOfThreads: 4,
numberOfThreadsBatch: 4,
);
const sources = {
'asr': (
'ggml-org/Qwen3-ASR-0.6B-GGUF',
'928ab958557df9aa2ef1c93e0e83c7ad0933fae2',
),
'omni': (
'ggml-org/Qwen2.5-Omni-3B-GGUF',
'75f1b73b657a50f5092502799457ccb4a4a1f9df',
),
'dialogue': (
'unsloth/Qwen3.5-0.8B-GGUF',
'6ab461498e2023f6e3c1baea90a8f0fe38ab64d0',
),
'tts': (
'ggml-org/Qwen3-TTS-12Hz-1.7B-Base-GGUF',
'ca27d74bc954b73dadab5b71ca265d87fc861a7c',
),
'smallTts': (
'mradermacher/Qwen3-TTS-12Hz-0.6B-Base-GGUF',
'0762b31e9cd99274d49dca4e895bd81863134f08',
),
};
ModelSource model(String family, String file) {
final (repo, revision) = sources[family]!;
return ModelSource.huggingFace(
repoId: repo,
revision: revision,
filePath: file,
);
}
Future<void> main(List<String> args) async {
if (args.isEmpty ||
args.length > 3 ||
(args.length > 1 && !['small', 'staged', 'audio'].contains(args[1]))) {
stderr.writeln(
'Usage: dart run bin/voice.dart question.wav '
'[small|staged|audio] [voice-reference.wav]',
);
exitCode = 64;
return;
}
final question = args[0];
final small = args.length == 1 || args[1] == 'small';
final directAudio = args.length > 1 && args[1] == 'audio';
final reference = args.length == 3 ? args[2] : null;
for (final path in [question, if (reference != null) reference]) {
if (!await File(path).exists()) {
throw ArgumentError('Audio file not found:$path');
}
}
String? transcript;
if (!directAudio) {
final asr = await SpeechToTextEngine.load(
SpeechToTextModel(
model('asr', 'Qwen3-ASR-0.6B-Q8_0.gguf'),
projector: model('asr', 'mmproj-Qwen3-ASR-0.6B-Q8_0.gguf'),
adapter: const Qwen3AsrAdapter(),
),
params: params,
);
try {
final result = await asr.transcribeOnce(
SpeechToTextRequest(
audio: SpeechAudioFileInput(question),
contextPrompt: 'Mo, Pip, Dash, Llama Village',
),
);
transcript = result.text.trim();
if (transcript.isEmpty) throw StateError('No speech recognized');
stdout.writeln('Dash:$transcript');
} finally {
await asr.dispose();
}
}
final llm = await LlamaEngine.load(
LlamaModel(
small
? model('dialogue', 'Qwen3.5-0.8B-Q4_K_M.gguf')
: model('omni', 'Qwen2.5-Omni-3B-Q4_K_M.gguf'),
projector: directAudio
? model('omni', 'mmproj-Qwen2.5-Omni-3B-Q8_0.gguf')
: null,
),
params: params,
);
final reply = StringBuffer();
try {
if (directAudio && !(await llm.capabilities).supportsAudio) {
throw StateError('此模型/运行时无法接受音频');
}
await for (final chunk in llm.create(
[
const LlamaChatMessage.fromText(
role: LlamaChatRole.system,
text:
'你是 Mo,一只友好的面包师羊驼。Dash 正在拜访你。'
"已知事实:Pip 的红围巾在池塘边的芦苇丛里。"
'用一句简短的英文回答访客。'
'不要舞台提示。不要编造你不知道的事实。',
),
if (transcript != null)
LlamaChatMessage.fromText(role: LlamaChatRole.user, text: transcript)
else
LlamaChatMessage.withContent(
role: LlamaChatRole.user,
content: [LlamaAudioContent(path: question)],
),
],
enableThinking: false,
params: const GenerationParams(maxTokens: 100, temp: 0, seed: 42),
)) {
reply.write(chunk.text);
}
} finally {
await llm.dispose();
}
final text = reply.toString().trim();
if (text.isEmpty) throw StateError('模型没有返回回复');
stdout.writeln('Mo:$text');
final tts = await TextToSpeechEngine.load(
TextToSpeechModel(
small
? model('smallTts', 'Qwen3-TTS-12Hz-0.6B-Base.Q4_K_M.gguf')
: model('tts', 'Qwen3-TTS-12Hz-1.7B-Base-Q4_K_M.gguf'),
projector: small
? model('smallTts', 'Qwen3-TTS-12Hz-0.6B-Base.mmproj-Q8_0.gguf')
: model('tts', 'mmproj-Qwen3-TTS-12Hz-1.7B-Base-Q8_0.gguf'),
adapter: const Qwen3TtsAdapter(),
),
params: params,
);
try {
final audio = await tts.synthesizeOnce(
TextToSpeechRequest(
text: text,
language: 'en',
seed: 42,
speakerReference: reference == null
? null
: SpeechAudioFileInput(reference),
),
);
await File('reply.wav').writeAsBytes(audio.toWavBytes());
stdout.writeln('Saved reply.wav');
} finally {
await tts.dispose();
}
}DART
3. 向 Mo 提一个问题
say -o question.aiff "Mo,你见过 Pip 的红围巾吗?"
afconvert -f WAVE -d LEI16@16000 -c 1 question.aiff question.wav
dart run bin/voice.dart question.wav small
afplay reply.wav
这会生成一个合成问题,这样你无需麦克风包或下载示例,就能试通整条路径。你可以将question.wav替换为你自己的短 WAV 文件。程序会打印转录文本和回答,然后保存reply.wav到项目目录中。它不会录制麦克风。
程序内部的主要调用如下:
// ASR:录音变成文本。
final result = await asr.transcribeOnce(
SpeechToTextRequest(
audio: SpeechAudioFileInput(question),
contextPrompt: 'Mo, Pip, Dash, Llama Village',
),
);
// TTS:生成的回答变成 WAV 文件。
final audio = await tts.synthesizeOnce(
TextToSpeechRequest(text: text, language: 'en', seed: 42),
);
await File('reply.wav').writeAsBytes(audio.toWavBytes()));
这些片段位于完整程序中已加载引擎的try/finally代码块内。它们之间,llm.create使用转录文本和 Mo 提供的事实来回答。
用音频尝试同一个问题
将模式改为 download,并加载 Omni 支持音频的模型和投影器,以及原始的 1.7B TTS 模型对:
dart run bin/voice.dart question.wav audio
afplay reply.wav
该路径跳过 ASR,将LlamaAudioContent(path: question)发送给对话模型。程序在要求它回答之前会检查supportsAudio。该路径使用的文件总计约5.13 GB;将三种模式全部缓存约需7.44 GB。
在游戏中,Mo 只应收到他被允许透露的事实。DecisionEngine可以在对话生成之前选择一个允许的意图。友好的人设提示并不会强制执行这些规则。
让它听起来像 Mo
一种可能的生产工作流:准备一个声音,保存一份参考,然后将其复用于新的对话。概念示意图。
我希望 Mo 听起来温暖而不慌不忙。Dash 可以更明亮、更迅捷。一段简短的参考音频可以让多个角色共用一个 TTS 模型,同时听起来各不相同。当玩家以 Dash 的身份说话时,他们本身就已经提供了他的声音。
完整程序接受一个可选的第三个参数。使用small用于 0.8B/0.6B 这一对,或者staged用于原始的 3B/1.7B 这一对:
dart run bin/voice.dart question.wav small mo-reference.wav
afplay reply.wav
使用一段你有权使用的简短、清晰的录音。在我的测试中,我用 macOS 的 Daniel 声音制作了一段合成参考。在TextToSpeechRequest内部,程序将其作为speakerReference: SpeechAudioFileInput(reference)传入。
Qwen 的声音设计工作流还允许你在开发阶段创建一个声音,然后将生成的音频片段与 Base 模型一起使用。玩家只需下载合成模型和参考音频,无需声音设计模型。我在这里没有测试该工作流。
参考音频赋予了 Mo 一个声音,但测试过的 API 并没有诸如“紧张地说出这句话”这样的指令。这不仅仅是一个缺失的 Dart 参数。Qwen 的 1.7B CustomVoice 和 VoiceDesign 模型支持指令控制;这里使用的 Base 模型不提供同样的模式。要暴露该能力,需要一个兼容的模型、原生运行时支持以及一个 Dart API。仅改变对话提示只会改变措辞。
更小的模型,更小的下载量。更快的对话?
我在同一台 M4 Max(64 GB)上测试了更小的那一对模型,使用 llamadart 0.11.0。两条路径都保留了 Qwen3-ASR 0.6B,并使用了相同的合成问题和 Daniel 声音参考。
| 文本路径 | 模型文件 | 完整轮次,中位数 | 进程峰值 RSS |
|---|---|---|---|
| Omni 3B + TTS 1.7B | 4.61 GB | 4.27 s | 3.86 GiB |
| Qwen3.5 0.8B + TTS 0.6B | 2.31 GB | 5.83 s | 2.51 GiB |
下载量减半,进程峰值 RSS 约减少 35%,但在本次试点中轮次更慢。更小的对话模型给出了更长的回复:
3B:“是的,Pip 的红围巾在池塘边的芦苇丛里。”
0.8B:“是的,我看到了!它被塞在池塘边的芦苇丛里。”
0.8B 模型同样忽略了一句话的指令。字数越多,需要生成的语音就越多。这些是带有不同回复的完整应用轮次,所以我也单独测试了各个组件:
| 已加载组件 | 生成时间,中位数 | 进程峰值 RSS |
|---|---|---|
| Omni 3B 对话 | 171 ms | 2.57 GiB |
| Qwen3.5 0.8B 对话 | 96 ms | 1.08 GiB |
| Qwen3-TTS 1.7B | 1.78 s | 3.85 GiB |
| Qwen3-TTS 0.6B | 1.78 s | 2.48 GiB |
对于 TTS,两个模型都准确说出了“Yes, Pip's red scarf is in the reeds beside the pond.”。1.7B 的音频片段时长为 4.16–4.56 秒;0.6B 的音频片段时长为 4.80–6.48 秒。较小模型的合成时间范围为 1.78 到 2.53 秒。更小并不意味着等待时间一定更短。
Mo 说对了吗?
两个对话模型在被问到时都正确给出了位置和颜色。但当我问“谁把围巾放进了池塘?”时,两个模型都归咎于 Pip。这一事实从未被提供过,即使提示词中写了“不要编造你不知道的事实”。较小模型所说的“我看到了”还添加了一个关于 Mo 如何知道此事的无依据说法。
全部六段测量过的 TTS 音频片段用 Qwen3-ASR 转写后都还原为预期句子。这是一项可懂度检查,而不是对自然度、情感或与 Daniel 相似度的听感评分。在选择角色声音之前,我仍会多听几行。
在这个原型的存储预算下,我会使用较小的那一对模型。我会把游戏的知识规则放在提示词之外,并在声称有延迟优势之前先测量真实对话。
测试条件和模型文件这是一次小型开发机试点,不是设备基准测试。其他开发工作负载处于活动状态;村庄渲染器未运行。完整轮次测试交替进行两种配置,丢弃每种配置的第一轮,然后对每种配置测量三个全新进程的轮次。基线轮次范围为 3.76 到 4.41 秒;较小模型对的轮次范围为 5.82 到 5.91 秒。计时包括模型加载和释放,但不包括进程启动、下载、录制和播放。峰值 RSS 每 100 ms 采样一次,是三次运行中观察到的最大峰值。它是进程内存,不是总 GPU 或设备内存。
组件测试在各自独立的进程中加载每个模型,并丢弃一次预热。对话使用了三个问题:围巾的位置、是谁把它放在那里的,以及它的位置加颜色。TTS 用相同的参考重复同一句话三次。组件耗时不包括加载和销毁;内存使用进程的高水位 RSS。两个对话模型都使用 temperature 0、seed 42、100 token 上限、禁用思考、4096 token 上下文和四个线程。TTS 使用 seed 42;其波形在多次调用之间仍有变化。
对话权重为 Q4_K_M;音频投影器为 Q8_0。新增文件为Unsloth 的 Qwen3.5 0.8B 转换和mradermacher 的 Qwen3-TTS 0.6B Base 转换。确切的修订版本和匹配的投影器文件名已在上面的程序中固定。为不同 TTS 运行时制作的 GGUF 文件不能自动互换。
让玩家在 Mo 准备时阅读
此 TTS API 在播放前返回完整音频。其进度事件不是可播放的音频块。我会立即将 Mo 的回复显示为文本,然后在合成完成后播放。
对于第一个游戏版本,我会使用按键说话,在 Mo 说话时停止录音,并在玩家开始另一轮时丢弃旧回复。当语音关闭或模型无法加载时,文本应保持可用。在将其添加到游戏之前,我仍需测量语音与渲染的并行表现。


