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 核心步骤回顾

  1. 服务部署与验证:确保通过Xinference部署的Fish Speech-1.5 API可以正常访问和调用。
  2. H5网页集成:使用JavaScript的Fetch API调用后端服务,获取音频流并通过 <audio> 标签播放。核心是处理跨域和用户体验。
  3. Android集成:使用Retrofit等库封装网络请求,将音频数据保存为临时文件,然后通过Android的 MediaPlayer 进行播放。需要注意生命周期管理和资源释放。
  4. iOS集成:使用 URLSession 进行网络请求,将音频数据保存到临时目录,然后通过 AVAudioPlayer 框架播放。需要处理ATS安全策略。

5.2 进阶优化建议

当你完成了基础集成后,可以考虑以下方向来提升应用的稳定性和用户体验:

  • 网络优化

    • 超时与重试:为网络请求设置合理的超时时间,并实现失败重试机制。
    • 离线缓存:对于合成过的文本,可以将音频文件缓存到本地,下次直接播放,节省流量和等待时间。
    • 断点续传:如果合成很长的文本,可以考虑支持断点续传(但这需要后端API支持)。
  • 播放功能增强

    • 播放控制:实现播放、暂停、停止、快进、快退等完整控制。
    • 播放列表:支持多个语音片段的队列播放。
    • 后台播放:在iOS和Android上配置后台播放权限,让语音在App退到后台时也能继续(适用于听书类应用)。
  • 性能与稳定性

    • 内存管理:及时释放不再使用的音频文件和播放器资源,防止内存泄漏。
    • 错误监控:收集合成失败、播放错误等日志,便于排查问题。
    • 多音色与参数:如果Fish Speech模型支持,可以开放更多参数(如语速、音高、不同说话人)供用户选择。
  • 安全与合规

    • 使用HTTPS:在生产环境中,务必为你的API服务配置HTTPS,以保证数据传输安全。
    • 鉴权与限流:为API接口添加简单的Token鉴权,防止被滥用。可以设置调用频率限制。

把一项强大的AI能力从演示页面搬到真实可用的产品里,中间需要这些扎实的工程化工作。希望这份指南能帮你跨过这道坎,顺利在你的网页或App中响起Fish Speech合成的、自然流畅的语音。


获取更多AI镜像

想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐