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
|
||
}
|
||
}
|