集成阿里云 NUI 语音合成 SDK

This commit is contained in:
2026-07-07 16:05:39 +08:00
parent 0aa8b14e1f
commit 9378748a4d
26 changed files with 2095 additions and 16 deletions

View File

@ -0,0 +1,130 @@
//
// AliyunPCMStreamingPlayer.swift
// suixinkan
//
import AVFoundation
import Foundation
/// TTS PCM 便
protocol AliyunPCMStreamingPlaying: AnyObject {
/// 16-bit mono PCM
func start(sampleRate: Double)
/// 16-bit little-endian mono PCM
func write(_ data: Data)
///
func drain(_ completion: @escaping () -> Void)
///
func stop()
}
/// AVAudioEngine 16-bit PCM
final class AliyunPCMStreamingPlayer: AliyunPCMStreamingPlaying {
private let stateQueue = DispatchQueue(label: "com.zhifly.suixinkan.aliyunNLS.pcmPlayer")
private let engine = AVAudioEngine()
private let playerNode = AVAudioPlayerNode()
private var format: AVAudioFormat?
private var scheduledBufferCount = 0
private var draining = false
private var drainCompletion: (() -> Void)?
private var started = false
func start(sampleRate: Double) {
stateQueue.sync {
configureSession()
if engine.attachedNodes.contains(playerNode) == false {
engine.attach(playerNode)
}
let audioFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: sampleRate, channels: 1, interleaved: false)
format = audioFormat
if let audioFormat {
engine.disconnectNodeOutput(playerNode)
engine.connect(playerNode, to: engine.mainMixerNode, format: audioFormat)
}
if engine.isRunning == false {
try? engine.start()
}
if playerNode.isPlaying == false {
playerNode.play()
}
started = true
draining = false
drainCompletion = nil
}
}
func write(_ data: Data) {
guard data.count >= 2 else { return }
stateQueue.async {
guard self.started, let format = self.format else { return }
let buffer = Self.makePCMBuffer(from: data, format: format)
guard let buffer else { return }
self.scheduledBufferCount += 1
self.playerNode.scheduleBuffer(buffer, completionCallbackType: .dataPlayedBack) { [weak self] _ in
self?.stateQueue.async {
guard let self else { return }
self.scheduledBufferCount = max(0, self.scheduledBufferCount - 1)
self.completeDrainIfNeeded()
}
}
}
}
func drain(_ completion: @escaping () -> Void) {
stateQueue.async {
self.draining = true
self.drainCompletion = completion
self.completeDrainIfNeeded()
}
}
func stop() {
stateQueue.sync {
playerNode.stop()
engine.stop()
scheduledBufferCount = 0
draining = false
drainCompletion = nil
started = false
}
}
private func completeDrainIfNeeded() {
guard draining, scheduledBufferCount == 0 else { return }
let completion = drainCompletion
drainCompletion = nil
draining = false
started = false
playerNode.stop()
completion?()
}
private func configureSession() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback, options: [.allowBluetoothHFP, .mixWithOthers])
try? session.setActive(true)
}
private static func makePCMBuffer(from data: Data, format: AVAudioFormat) -> AVAudioPCMBuffer? {
let frameCount = data.count / MemoryLayout<Int16>.size
guard frameCount > 0,
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frameCount)),
let channel = buffer.floatChannelData?[0] else {
return nil
}
buffer.frameLength = AVAudioFrameCount(frameCount)
data.withUnsafeBytes { raw in
let bytes = raw.bindMemory(to: UInt8.self)
for index in 0..<frameCount {
let low = UInt16(bytes[index * 2])
let high = UInt16(bytes[index * 2 + 1]) << 8
let sample = Int16(bitPattern: high | low)
channel[index] = Float(sample) / Float(Int16.max)
}
}
return buffer
}
}