Fish Speech-1.5多终端适配:H5网页嵌入、Android SDK、iOS语音播放集成
Fish Speech-1.5多终端适配:H5网页嵌入、Android SDK、iOS语音播放集成
Fish Speech-1.5是一个让人惊艳的文本转语音模型,它基于超过100万小时的多语言音频数据训练而成,能生成非常自然、富有表现力的语音。通过Xinference(2.0.0)部署后,你可以在Web UI里轻松体验它的强大能力。
但真正的价值在于,如何把这个能力集成到你的实际项目中?比如,你想在自己的网站里加入语音播报功能,或者在移动App里让虚拟助手开口说话。这篇文章,我就来手把手带你实现Fish Speech-1.5在H5网页、Android和iOS三大终端上的集成,让你能真正把这个技术用起来。
1. 准备工作:部署与基础调用
在开始集成之前,你得先有一个运行起来的Fish Speech-1.5服务。这里假设你已经通过Xinference完成了部署,并且可以通过Web UI正常生成语音。
1.1 确认服务状态
首先,确保你的模型服务已经成功启动。你可以通过查看日志来确认:
cat /root/workspace/model_server.log
如果看到服务启动成功的相关日志,就说明一切正常。初次加载模型可能需要一些时间,请耐心等待。
1.2 获取API访问信息
终端集成需要通过API来调用模型。你需要知道服务的地址(IP和端口)。通常,Xinference部署后,会提供一个Web UI地址,比如 http://你的服务器IP:9997。这个地址的根路径就是你的API服务地址。
为了测试API是否可用,我们可以先用一个简单的curl命令试试水:
curl -X POST http://你的服务器IP:9997/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "fish-speech-1.5",
"input": "你好,世界!这是一段测试语音。",
"voice": "default",
"language": "zh"
}' \
--output test_audio.wav
如果命令执行成功,并且生成了一个 test_audio.wav 文件,用播放器打开能听到清晰的“你好,世界”,那么恭喜你,API调用这条路就通了。这是后续所有终端集成的基础。
2. H5网页嵌入实战
现在,我们来看看怎么在网页里使用这个语音合成能力。想象一下,你有一个新闻网站,想让用户能“听”新闻;或者有一个教育平台,需要语音朗读题目。H5集成是最直接的方式。
2.1 前端调用API
核心思路是,网页上的JavaScript代码去调用我们刚才测试过的那个API。下面是一个最基础的HTML示例,包含一个输入框、一个按钮和一个音频播放器。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Fish Speech H5 语音合成演示</title>
</head>
<body>
<h2>网页语音合成演示</h2>
<textarea id="textInput" rows="4" cols="50" placeholder="请输入要合成的文本...">欢迎使用Fish Speech语音合成服务。</textarea>
<br><br>
<button onclick="synthesizeSpeech()">生成语音</button>
<br><br>
<audio id="audioPlayer" controls></audio>
<script>
// 替换成你的实际服务器地址
const API_BASE_URL = 'http://你的服务器IP:9997/v1';
async function synthesizeSpeech() {
const text = document.getElementById('textInput').value;
const audioPlayer = document.getElementById('audioPlayer');
if (!text.trim()) {
alert('请输入文本!');
return;
}
// 显示加载状态
audioPlayer.src = '';
const button = event.target;
button.textContent = '生成中...';
button.disabled = true;
try {
const response = await fetch(`${API_BASE_URL}/audio/speech`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'fish-speech-1.5',
input: text,
voice: 'default', // 可根据需要选择音色
language: 'zh'
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 获取音频Blob数据
const audioBlob = await response.blob();
// 创建本地播放URL
const audioUrl = URL.createObjectURL(audioBlob);
audioPlayer.src = audioUrl;
// 可选:自动播放(注意浏览器自动播放策略)
// audioPlayer.play().catch(e => console.log("自动播放被阻止:", e));
} catch (error) {
console.error('语音合成失败:', error);
alert('语音合成失败,请检查控制台或网络。');
} finally {
// 恢复按钮状态
button.textContent = '生成语音';
button.disabled = false;
}
}
</script>
</body>
</html>
把这段代码保存为 index.html,用浏览器打开,填入你的服务器IP,就可以在网页里直接生成和播放语音了。
2.2 处理跨域与优化体验
在实际项目中,你可能会遇到跨域问题。因为你的网页域名和API服务器域名不同,浏览器出于安全考虑会阻止请求。解决方法是在部署Xinference的服务端配置CORS(跨域资源共享)。
此外,为了更好的用户体验,你还可以:
- 添加加载动画:在生成语音时显示一个旋转的加载图标。
- 错误处理:更优雅地提示网络错误、服务器错误或合成失败。
- 参数扩展:让用户可以选择语速、音调、不同音色等(如果模型支持)。
- 兼容性处理:确保代码在不同浏览器上都能正常工作。
3. Android SDK集成指南
对于Android应用,比如阅读类App、语音助手、教育软件等,集成语音合成功能能极大提升用户体验。我们通过封装网络请求,可以轻松地将Fish Speech-1.5的能力接入Android App。
3.1 核心网络请求封装
首先,在Android项目中,你需要添加网络请求库的依赖,比如Retrofit,它是处理HTTP请求的利器。在 app/build.gradle 文件中添加:
dependencies {
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0' // 用于JSON解析
// 如果你需要处理更复杂的响应(如直接获取二进制音频流),可以添加scalars转换器
// implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'
}
然后,我们定义一个数据模型和API接口:
// SpeechRequest.kt
data class SpeechRequest(
val model: String = "fish-speech-1.5",
val input: String,
val voice: String = "default",
val language: String = "zh"
// 可以添加更多参数,如 speed, pitch 等
)
// FishSpeechApiService.kt
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.POST
interface FishSpeechApiService {
@POST("audio/speech") // 相对路径,基础URL在Retrofit构建器中设置
fun synthesizeSpeech(@Body request: SpeechRequest): Call<ResponseBody> // 直接返回二进制流
}
接下来,创建一个管理类来处理语音合成:
// FishSpeechManager.kt
import android.content.Context
import android.media.MediaPlayer
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
class FishSpeechManager private constructor(context: Context) {
companion object {
private var instance: FishSpeechManager? = null
fun getInstance(context: Context): FishSpeechManager {
return instance ?: synchronized(this) {
instance ?: FishSpeechManager(context.applicationContext).also { instance = it }
}
}
}
private val appContext: Context = context
private val apiService: FishSpeechApiService
private var mediaPlayer: MediaPlayer? = null
private var currentAudioFile: File? = null
init {
val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) // 合成可能需要时间,设置长一点
.readTimeout(60, TimeUnit.SECONDS)
.build()
val retrofit = Retrofit.Builder()
.baseUrl("http://你的服务器IP:9997/v1/") // 设置基础URL
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
apiService = retrofit.create(FishSpeechApiService::class.java)
}
interface SynthesisCallback {
fun onSuccess(audioFile: File)
fun onFailure(errorMessage: String)
fun onProgress(progress: Int) // 可用于显示进度,但简单API可能不支持
}
fun synthesizeText(text: String, callback: SynthesisCallback) {
if (text.isBlank()) {
callback.onFailure("输入文本为空")
return
}
val request = SpeechRequest(input = text)
val call = apiService.synthesizeSpeech(request)
call.enqueue(object : Callback<ResponseBody> {
override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
if (response.isSuccessful && response.body() != null) {
// 将音频流保存到文件
val body = response.body()!!
try {
// 创建临时文件存储音频
val tempFile = File.createTempFile("fish_speech_", ".wav", appContext.cacheDir)
val inputStream = body.byteStream()
val outputStream = FileOutputStream(tempFile)
inputStream.copyTo(outputStream)
outputStream.close()
inputStream.close()
currentAudioFile = tempFile
callback.onSuccess(tempFile)
} catch (e: Exception) {
Log.e("FishSpeechManager", "保存音频文件失败", e)
callback.onFailure("保存音频失败: ${e.message}")
}
} else {
val errorMsg = "请求失败: ${response.code()} - ${response.message()}"
Log.e("FishSpeechManager", errorMsg)
callback.onFailure(errorMsg)
}
}
override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
Log.e("FishSpeechManager", "网络请求失败", t)
callback.onFailure("网络请求失败: ${t.message}")
}
})
}
fun playAudio(file: File) {
stopPlayback() // 停止当前播放
mediaPlayer = MediaPlayer().apply {
setDataSource(file.path)
prepareAsync()
setOnPreparedListener { it.start() }
setOnCompletionListener { stopPlayback() }
setOnErrorListener { mp, what, extra ->
Log.e("FishSpeechManager", "播放错误: what=$what, extra=$extra")
false
}
}
}
fun stopPlayback() {
mediaPlayer?.release()
mediaPlayer = null
}
fun cleanup() {
stopPlayback()
currentAudioFile?.delete() // 清理临时文件
currentAudioFile = null
}
}
3.2 在Activity中使用
在Android的Activity或Fragment中,你可以这样调用:
// MainActivity.kt 示例
import android.os.Bundle
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import java.io.File
class MainActivity : AppCompatActivity() {
private lateinit var editText: EditText
private lateinit var synthButton: Button
private lateinit var playButton: Button
private lateinit var fishSpeechManager: FishSpeechManager
private var currentAudioFile: File? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
editText = findViewById(R.id.editText)
synthButton = findViewById(R.id.synthButton)
playButton = findViewById(R.id.playButton)
playButton.isEnabled = false
fishSpeechManager = FishSpeechManager.getInstance(applicationContext)
synthButton.setOnClickListener {
val text = editText.text.toString()
synthButton.isEnabled = false
synthButton.text = "合成中..."
fishSpeechManager.synthesizeText(text, object : FishSpeechManager.SynthesisCallback {
override fun onSuccess(audioFile: File) {
runOnUiThread {
synthButton.isEnabled = true
synthButton.text = "合成语音"
currentAudioFile = audioFile
playButton.isEnabled = true
Toast.makeText(this@MainActivity, "语音合成成功!", Toast.LENGTH_SHORT).show()
}
}
override fun onFailure(errorMessage: String) {
runOnUiThread {
synthButton.isEnabled = true
synthButton.text = "合成语音"
Toast.makeText(this@MainActivity, "失败: $errorMessage", Toast.LENGTH_LONG).show()
}
}
override fun onProgress(progress: Int) {
// 如果API支持进度回调,可以在这里更新UI
}
})
}
playButton.setOnClickListener {
currentAudioFile?.let { file ->
fishSpeechManager.playAudio(file)
} ?: run {
Toast.makeText(this, "请先合成语音", Toast.LENGTH_SHORT).show()
}
}
}
override fun onDestroy() {
super.onDestroy()
fishSpeechManager.cleanup()
}
}
记得在 AndroidManifest.xml 中添加网络权限:
<uses-permission android:name="android.permission.INTERNET" />
这样,一个基本的Android语音合成功能就完成了。你可以根据需求,增加音色选择、语速调节、播放列表管理等功能。
4. iOS语音播放集成
在iOS平台上,无论是SwiftUI还是UIKit项目,集成思路类似:网络请求获取音频数据,然后用系统框架播放。这里以Swift语言为例。
4.1 网络请求与音频播放
首先,创建一个管理网络请求和音频播放的类:
// FishSpeechService.swift
import Foundation
import AVFoundation
class FishSpeechService: NSObject, AVAudioPlayerDelegate {
static let shared = FishSpeechService()
private override init() {}
private var audioPlayer: AVAudioPlayer?
private let baseURL = "http://你的服务器IP:9997/v1" // 替换为你的地址
typealias SynthesisCompletion = (Result<URL, Error>) -> Void
func synthesizeSpeech(text: String, completion: @escaping SynthesisCompletion) {
guard !text.isEmpty else {
completion(.failure(NSError(domain: "FishSpeech", code: -1, userInfo: [NSLocalizedDescriptionKey: "输入文本为空"])))
return
}
let url = URL(string: "\(baseURL)/audio/speech")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let requestBody: [String: Any] = [
"model": "fish-speech-1.5",
"input": text,
"voice": "default",
"language": "zh"
]
do {
request.httpBody = try JSONSerialization.data(withJSONObject: requestBody)
} catch {
completion(.failure(error))
return
}
let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
guard let self = self else { return }
if let error = error {
DispatchQueue.main.async {
completion(.failure(error))
}
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let audioData = data else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
let error = NSError(domain: "FishSpeech", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "服务器返回错误: \(statusCode)"])
DispatchQueue.main.async {
completion(.failure(error))
}
return
}
// 将音频数据保存到临时文件
do {
let tempDir = FileManager.default.temporaryDirectory
let tempFileURL = tempDir.appendingPathComponent(UUID().uuidString).appendingPathExtension("wav")
try audioData.write(to: tempFileURL)
DispatchQueue.main.async {
completion(.success(tempFileURL))
}
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
task.resume()
}
func playAudio(from fileURL: URL) throws {
// 停止当前播放
stopPlayback()
audioPlayer = try AVAudioPlayer(contentsOf: fileURL)
audioPlayer?.delegate = self
audioPlayer?.prepareToPlay()
audioPlayer?.play()
}
func stopPlayback() {
audioPlayer?.stop()
audioPlayer = nil
}
var isPlaying: Bool {
return audioPlayer?.isPlaying ?? false
}
// MARK: - AVAudioPlayerDelegate
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
stopPlayback()
// 可以在这里发送播放完成的通知
}
func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
stopPlayback()
print("音频解码错误: \(error?.localizedDescription ?? "未知错误")")
}
}
4.2 在SwiftUI视图中使用
下面是一个简单的SwiftUI视图,演示如何调用上述服务:
// ContentView.swift
import SwiftUI
struct ContentView: View {
@State private var inputText: String = "欢迎使用Fish Speech语音合成。"
@State private var isSynthesizing: Bool = false
@State private var currentAudioURL: URL?
@State private var errorMessage: String?
@State private var showError: Bool = false
var body: some View {
VStack(spacing: 20) {
Text("Fish Speech iOS 演示")
.font(.largeTitle)
.padding()
TextEditor(text: $inputText)
.frame(height: 150)
.padding(4)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
)
.padding(.horizontal)
Button(action: synthesizeSpeech) {
HStack {
if isSynthesizing {
ProgressView()
.scaleEffect(0.8)
Text("合成中...")
} else {
Image(systemName: "waveform")
Text("合成语音")
}
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(isSynthesizing || inputText.isEmpty)
.padding(.horizontal)
if let url = currentAudioURL {
HStack(spacing: 20) {
Button(action: playAudio) {
HStack {
Image(systemName: FishSpeechService.shared.isPlaying ? "pause.circle.fill" : "play.circle.fill")
Text(FishSpeechService.shared.isPlaying ? "播放中" : "播放")
}
.padding()
.background(Color.green)
.foregroundColor(.white)
.cornerRadius(10)
}
Button(action: stopAudio) {
HStack {
Image(systemName: "stop.circle.fill")
Text("停止")
}
.padding()
.background(Color.red)
.foregroundColor(.white)
.cornerRadius(10)
}
}
}
Spacer()
}
.padding()
.alert("错误", isPresented: $showError, presenting: errorMessage) { _ in
Button("确定", role: .cancel) { }
} message: { message in
Text(message)
}
}
private func synthesizeSpeech() {
guard !inputText.isEmpty else { return }
isSynthesizing = true
errorMessage = nil
FishSpeechService.shared.synthesizeSpeech(text: inputText) { result in
isSynthesizing = false
switch result {
case .success(let url):
self.currentAudioURL = url
print("音频文件已保存至: \(url.path)")
case .failure(let error):
self.errorMessage = error.localizedDescription
self.showError = true
print("合成失败: \(error)")
}
}
}
private func playAudio() {
guard let url = currentAudioURL else { return }
do {
try FishSpeechService.shared.playAudio(from: url)
} catch {
errorMessage = "播放失败: \(error.localizedDescription)"
showError = true
}
}
private func stopAudio() {
FishSpeechService.shared.stopPlayback()
}
}
重要提示:在iOS中,你需要配置App Transport Security (ATS) 以允许HTTP请求(如果你的服务器没有使用HTTPS)。在 Info.plist 中添加:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
对于生产环境,强烈建议使用HTTPS并配置正确的ATS例外。
5. 总结与进阶建议
通过上面的步骤,你应该已经成功将Fish Speech-1.5的语音合成能力集成到了H5网页、Android和iOS应用中。我们来回顾一下关键点,并看看还能如何做得更好。
5.1 核心步骤回顾
- 服务部署与验证:确保通过Xinference部署的Fish Speech-1.5 API可以正常访问和调用。
- H5网页集成:使用JavaScript的Fetch API调用后端服务,获取音频流并通过
<audio>标签播放。核心是处理跨域和用户体验。 - Android集成:使用Retrofit等库封装网络请求,将音频数据保存为临时文件,然后通过Android的
MediaPlayer进行播放。需要注意生命周期管理和资源释放。 - iOS集成:使用
URLSession进行网络请求,将音频数据保存到临时目录,然后通过AVAudioPlayer框架播放。需要处理ATS安全策略。
5.2 进阶优化建议
当你完成了基础集成后,可以考虑以下方向来提升应用的稳定性和用户体验:
-
网络优化:
- 超时与重试:为网络请求设置合理的超时时间,并实现失败重试机制。
- 离线缓存:对于合成过的文本,可以将音频文件缓存到本地,下次直接播放,节省流量和等待时间。
- 断点续传:如果合成很长的文本,可以考虑支持断点续传(但这需要后端API支持)。
-
播放功能增强:
- 播放控制:实现播放、暂停、停止、快进、快退等完整控制。
- 播放列表:支持多个语音片段的队列播放。
- 后台播放:在iOS和Android上配置后台播放权限,让语音在App退到后台时也能继续(适用于听书类应用)。
-
性能与稳定性:
- 内存管理:及时释放不再使用的音频文件和播放器资源,防止内存泄漏。
- 错误监控:收集合成失败、播放错误等日志,便于排查问题。
- 多音色与参数:如果Fish Speech模型支持,可以开放更多参数(如语速、音高、不同说话人)供用户选择。
-
安全与合规:
- 使用HTTPS:在生产环境中,务必为你的API服务配置HTTPS,以保证数据传输安全。
- 鉴权与限流:为API接口添加简单的Token鉴权,防止被滥用。可以设置调用频率限制。
把一项强大的AI能力从演示页面搬到真实可用的产品里,中间需要这些扎实的工程化工作。希望这份指南能帮你跨过这道坎,顺利在你的网页或App中响起Fish Speech合成的、自然流畅的语音。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。
更多推荐

所有评论(0)