From 935acf773a183bd8691a3bb961b6e0d3ea362947 Mon Sep 17 00:00:00 2001 From: Ryu Akaike Date: Tue, 13 May 2025 11:16:42 +0900 Subject: [PATCH 1/3] - Added AVAudioSession.routeChangeNotification handling - Returns when AVAudioEngine.start() is failed - Fixed the indentation --- ios/Classes/SwiftDtmfPlugin.swift | 44 ++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/ios/Classes/SwiftDtmfPlugin.swift b/ios/Classes/SwiftDtmfPlugin.swift index 7de077c..8ea5835 100644 --- a/ios/Classes/SwiftDtmfPlugin.swift +++ b/ios/Classes/SwiftDtmfPlugin.swift @@ -4,24 +4,35 @@ import AVFoundation import CallKit public class SwiftDtmfPlugin: NSObject, FlutterPlugin { - + var _engine: AVAudioEngine var _player:AVAudioPlayerNode var _mixer: AVAudioMixerNode - public override init() { - _engine = AVAudioEngine(); - _player = AVAudioPlayerNode() - _mixer = _engine.mainMixerNode; + public override init() { + _engine = AVAudioEngine(); + _player = AVAudioPlayerNode() + _mixer = _engine.mainMixerNode; + + super.init() + + NotificationCenter.default.addObserver(self, selector: #selector(handleRouteChange), name: AVAudioSession.routeChangeNotification, object: nil) + } - super.init() + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "flutter_dtmf", binaryMessenger: registrar.messenger()) + let instance = SwiftDtmfPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) } - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "flutter_dtmf", binaryMessenger: registrar.messenger()) - let instance = SwiftDtmfPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } + @objc func handleRouteChange(notification: Notification){ + _player.stop() + if(_engine.isRunning){_engine.disconnectNodeOutput(_player)} + _engine.stop() + _engine = AVAudioEngine(); + _player = AVAudioPlayerNode() + _mixer = _engine.mainMixerNode; + } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { let arguments = call.arguments as? NSDictionary @@ -33,14 +44,14 @@ public class SwiftDtmfPlugin: NSObject, FlutterPlugin { let volume = arguments?["volume"] as? Double playTone(digits: digits, volume: volume, samplingRate: samplingRate, durationMs: durationMs, flutterResult: result) } - + } func playTone(digits: String, volume: Double?, samplingRate: Double, durationMs: Int, flutterResult: @escaping FlutterResult) { - + let _sampleRate = Float(samplingRate) - + if let tones = DTMF.tonesForString(digits) { let audioFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: Double(_sampleRate), channels: 2, interleaved: false)! @@ -69,6 +80,7 @@ public class SwiftDtmfPlugin: NSObject, FlutterPlugin { } catch let error as NSError { flutterResult(false) print("Engine start failed - \(error)") + return } _player.scheduleBuffer(buffer, at:nil,completionHandler:nil) @@ -77,7 +89,7 @@ public class SwiftDtmfPlugin: NSObject, FlutterPlugin { } _player.play() flutterResult(true) + } } - } - + } From 5d671ffc0079489c14297e599a2cab71ceae4677 Mon Sep 17 00:00:00 2001 From: Ryu Akaike Date: Wed, 12 Nov 2025 17:17:20 +0900 Subject: [PATCH 2/3] - Deactivate AudioSession when no tones playing. - Changed default sampling rate to 4000 to supress warning from AVAudioFormat. --- ios/Classes/SwiftDtmfPlugin.swift | 26 +++++++++++++++++++++++++- lib/dtmf.dart | 14 +++++++------- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/ios/Classes/SwiftDtmfPlugin.swift b/ios/Classes/SwiftDtmfPlugin.swift index 8ea5835..ea38116 100644 --- a/ios/Classes/SwiftDtmfPlugin.swift +++ b/ios/Classes/SwiftDtmfPlugin.swift @@ -8,6 +8,7 @@ public class SwiftDtmfPlugin: NSObject, FlutterPlugin { var _engine: AVAudioEngine var _player:AVAudioPlayerNode var _mixer: AVAudioMixerNode + var _playCount = 0 public override init() { _engine = AVAudioEngine(); @@ -83,13 +84,36 @@ public class SwiftDtmfPlugin: NSObject, FlutterPlugin { return } - _player.scheduleBuffer(buffer, at:nil,completionHandler:nil) + _player.scheduleBuffer(buffer, at:nil,completionCallbackType: .dataPlayedBack){ [weak self] _ in + Task{ @MainActor in + self?.stopTone() + } + } if (volume != nil) { _player.volume = Float(volume!) } _player.play() + _playCount += 1 flutterResult(true) } } + private func stopTone(){ + _playCount -= 1 + if _playCount > 0 { + return + } + + _player.stop() + if _engine.isRunning { + _engine.stop() + } + do{ + let session = AVAudioSession.sharedInstance() + try session.setCategory(.ambient, mode: .default, options: []) + try session.setActive(false, options: [.notifyOthersOnDeactivation]) + }catch{ + print("Deactivate failed - \(error)") + } + } } diff --git a/lib/dtmf.dart b/lib/dtmf.dart index 1239e53..cca9a9c 100644 --- a/lib/dtmf.dart +++ b/lib/dtmf.dart @@ -15,19 +15,19 @@ class Dtmf { /// static Future playTone( {required String digits, - int durationMs =160, - double samplingRate =500, + int durationMs = 160, + double samplingRate = 4000, double volume = 1, - bool ignoreDtmfSystemSettings=false, - bool forceMaxVolume=false}) async { - + bool ignoreDtmfSystemSettings = false, + bool forceMaxVolume = false}) async { final Map args = { "digits": digits, "samplingRate": samplingRate, "durationMs": durationMs, "volume": volume, - "ignoreDtmfSystemSettings":ignoreDtmfSystemSettings, - "forceMaxVolume":forceMaxVolume}; + "ignoreDtmfSystemSettings": ignoreDtmfSystemSettings, + "forceMaxVolume": forceMaxVolume + }; return await _channel.invokeMethod('playTone', args); } } From 2f7a249ed207e6d2a6fdb27ae96287e369f75d62 Mon Sep 17 00:00:00 2001 From: Ryu Akaike Date: Thu, 8 Jan 2026 14:38:10 +0900 Subject: [PATCH 3/3] Changed to use AVAudioPlayer to avoid deadlocks around Audio Session --- ios/Classes/SwiftDtmfPlugin.swift | 173 ++++++++++++++++-------------- 1 file changed, 95 insertions(+), 78 deletions(-) diff --git a/ios/Classes/SwiftDtmfPlugin.swift b/ios/Classes/SwiftDtmfPlugin.swift index ea38116..6d6a1ce 100644 --- a/ios/Classes/SwiftDtmfPlugin.swift +++ b/ios/Classes/SwiftDtmfPlugin.swift @@ -3,22 +3,11 @@ import UIKit import AVFoundation import CallKit -public class SwiftDtmfPlugin: NSObject, FlutterPlugin { +public class SwiftDtmfPlugin: NSObject, FlutterPlugin, AVAudioPlayerDelegate { - var _engine: AVAudioEngine - var _player:AVAudioPlayerNode - var _mixer: AVAudioMixerNode - var _playCount = 0 - - public override init() { - _engine = AVAudioEngine(); - _player = AVAudioPlayerNode() - _mixer = _engine.mainMixerNode; - - super.init() - - NotificationCenter.default.addObserver(self, selector: #selector(handleRouteChange), name: AVAudioSession.routeChangeNotification, object: nil) - } + private var _player: AVAudioPlayer? + private var _session = AVAudioSession.sharedInstance() + private var _playing = false public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "flutter_dtmf", binaryMessenger: registrar.messenger()) @@ -26,15 +15,6 @@ public class SwiftDtmfPlugin: NSObject, FlutterPlugin { registrar.addMethodCallDelegate(instance, channel: channel) } - @objc func handleRouteChange(notification: Notification){ - _player.stop() - if(_engine.isRunning){_engine.disconnectNodeOutput(_player)} - _engine.stop() - _engine = AVAudioEngine(); - _player = AVAudioPlayerNode() - _mixer = _engine.mainMixerNode; - } - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { let arguments = call.arguments as? NSDictionary if call.method == "playTone" @@ -48,72 +28,109 @@ public class SwiftDtmfPlugin: NSObject, FlutterPlugin { } - func playTone(digits: String, volume: Double?, samplingRate: Double, durationMs: Int, flutterResult: @escaping FlutterResult) - { + private func uint16le(_ v: UInt16) -> Data { + var x = v.littleEndian + return Data(bytes: &x, count: MemoryLayout.size) + } + private func uint32le(_ v: UInt32) -> Data { + var x = v.littleEndian + return Data(bytes: &x, count: MemoryLayout.size) + } + + func makeDtmfData(digits: String, samplingRate: Double, durationMs: Int) -> Data? { - let _sampleRate = Float(samplingRate) + guard let tones = DTMF.tonesForString(digits) else {return nil} - if let tones = DTMF.tonesForString(digits) { - let audioFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: Double(_sampleRate), channels: 2, interleaved: false)! - - // fill up the buffer with some samples - var allSamples = [Float]() - for tone in tones { - let samples = DTMF.generateDTMF(tone, markSpace: MarkSpaceType(Float(durationMs), Float(durationMs)), sampleRate: _sampleRate) - allSamples.append(contentsOf: samples) - } - - let frameCount = AVAudioFrameCount(allSamples.count) - let buffer = AVAudioPCMBuffer(pcmFormat: audioFormat, frameCapacity: frameCount)! - - buffer.frameLength = frameCount - let channelMemory = buffer.floatChannelData! - for channelIndex in 0 ..< Int(audioFormat.channelCount) { - let frameMemory = channelMemory[channelIndex] - memcpy(frameMemory, allSamples, Int(frameCount) * MemoryLayout.size) - } - - _engine.attach(_player) - _engine.connect(_player, to:_mixer, format:audioFormat) - - do { - try _engine.start() - } catch let error as NSError { - flutterResult(false) - print("Engine start failed - \(error)") - return - } - - _player.scheduleBuffer(buffer, at:nil,completionCallbackType: .dataPlayedBack){ [weak self] _ in - Task{ @MainActor in - self?.stopTone() - } - } - if (volume != nil) { - _player.volume = Float(volume!) + var pcm16 = [Int16]() + + for tone in tones { + let samples = DTMF.generateDTMF(tone, markSpace: MarkSpaceType(Float(durationMs), Float(durationMs)), sampleRate: Float(samplingRate)) + for sample in samples { + let v = max(-1.0, min(1.0, sample)) + pcm16.append(Int16(v * Float(Int16.max))) } - _player.play() - _playCount += 1 - flutterResult(true) } + + let numChannels: UInt16 = 1 + let bitsPerSample: UInt16 = 16 + let byteRate = UInt32(samplingRate) * UInt32(numChannels) * UInt32(bitsPerSample / 8) + let blockAlign = UInt16(numChannels) * (bitsPerSample / 8) + let dataSize = UInt32(pcm16.count * MemoryLayout.size) + + var data = Data() + + + // RIFF header + data.append("RIFF".data(using: .ascii)!) + data.append(uint32le(36 + dataSize)) + data.append("WAVE".data(using: .ascii)!) + + // fmt chunk + data.append("fmt ".data(using: .ascii)!) + data.append(uint32le(16)) // PCM fmt chunk size + data.append(uint16le(1)) // audio format 1=PCM + data.append(uint16le(numChannels)) + data.append(uint32le(UInt32(samplingRate))) + data.append(uint32le(byteRate)) + data.append(uint16le(blockAlign)) + data.append(uint16le(bitsPerSample)) + + // data chunk + data.append("data".data(using: .ascii)!) + data.append(uint32le(dataSize)) + + // PCM payload + pcm16.withUnsafeBufferPointer { bp in + data.append(Data(buffer: bp)) + } + + return data } - private func stopTone(){ - _playCount -= 1 - if _playCount > 0 { + func playTone(digits: String, volume: Double?, samplingRate: Double, durationMs: Int, flutterResult: @escaping FlutterResult) + { + + do { + try _session.setCategory(.playback, options: [.mixWithOthers]) + if !_session.isOtherAudioPlaying { + try _session.setActive(true) + } + } catch { + print("AudioSession setCategory/setActive failed: \(error)") + } + + guard let wavData = makeDtmfData(digits: digits, samplingRate: samplingRate, durationMs: durationMs) else { + flutterResult(false) return } - _player.stop() - if _engine.isRunning { - _engine.stop() + do{ + let p = try AVAudioPlayer(data: wavData) + p.volume = Float(volume ?? 1.0) + p.delegate = self + p.prepareToPlay() + DispatchQueue.main.async{ + self._player?.stop() + self._player = p + self._playing = p.play() + flutterResult(self._playing) + } + }catch{ + print("AVAudioPlayer init failed: \(error)") + flutterResult(false) } + + } + + public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + + _playing = false + do{ - let session = AVAudioSession.sharedInstance() - try session.setCategory(.ambient, mode: .default, options: []) - try session.setActive(false, options: [.notifyOthersOnDeactivation]) + try _session.setCategory(.ambient, mode: .default, options: []) + try _session.setActive(false, options: [.notifyOthersOnDeactivation]) }catch{ - print("Deactivate failed - \(error)") + print("AudioSession setCategory/setActive failed: \(error)") } } }