131 lines
4.4 KiB
Swift
131 lines
4.4 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|