Skip to content

Commit 7a8e03d

Browse files
authored
[HUME-9310] Flutter: use a custom plugin for audio on iOS (#140)
1 parent 10b79f7 commit 7a8e03d

22 files changed

+977
-134
lines changed

evi-flutter-example/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,6 @@ app.*.map.json
4646
/android/app/release
4747

4848
/pubspec.lock
49+
50+
51+
ios/Podfile.lock
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Miscellaneous
2+
*.class
3+
*.log
4+
*.pyc
5+
*.swp
6+
.DS_Store
7+
.atom/
8+
.buildlog/
9+
.history
10+
.svn/
11+
migrate_working_dir/
12+
13+
# IntelliJ related
14+
*.iml
15+
*.ipr
16+
*.iws
17+
.idea/
18+
19+
# The .vscode folder contains launch configuration and tasks you configure in
20+
# VS Code which you may wish to be included in version control, so this line
21+
# is commented out by default.
22+
#.vscode/
23+
24+
# Flutter/Dart/Pub related
25+
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
26+
/pubspec.lock
27+
**/doc/api/
28+
.dart_tool/
29+
build/
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# This file tracks properties of this Flutter project.
2+
# Used by Flutter tool to assess capabilities and perform upgrades etc.
3+
#
4+
# This file should be version controlled and should not be manually edited.
5+
6+
version:
7+
revision: "nixpkgs000000000000000000000000000000000"
8+
channel: "stable"
9+
10+
project_type: plugin
11+
12+
# Tracks metadata for the flutter migrate command
13+
migration:
14+
platforms:
15+
- platform: root
16+
create_revision: nixpkgs000000000000000000000000000000000
17+
base_revision: nixpkgs000000000000000000000000000000000
18+
- platform: ios
19+
create_revision: nixpkgs000000000000000000000000000000000
20+
base_revision: nixpkgs000000000000000000000000000000000
21+
22+
# User provided section
23+
24+
# List of Local paths (relative to this file) that should be
25+
# ignored by the migrate tool.
26+
#
27+
# Files that are not part of the templates will be ignored by default.
28+
unmanaged_files:
29+
- 'lib/main.dart'
30+
- 'ios/Runner.xcodeproj/project.pbxproj'
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
.idea/
2+
.vagrant/
3+
.sconsign.dblite
4+
.svn/
5+
6+
.DS_Store
7+
*.swp
8+
profile
9+
10+
DerivedData/
11+
build/
12+
GeneratedPluginRegistrant.h
13+
GeneratedPluginRegistrant.m
14+
15+
.generated/
16+
17+
*.pbxuser
18+
*.mode1v3
19+
*.mode2v3
20+
*.perspectivev3
21+
22+
!default.pbxuser
23+
!default.mode1v3
24+
!default.mode2v3
25+
!default.perspectivev3
26+
27+
xcuserdata
28+
29+
*.moved-aside
30+
31+
*.pyc
32+
*sync/
33+
Icon?
34+
.tags*
35+
36+
/Flutter/Generated.xcconfig
37+
/Flutter/ephemeral/
38+
/Flutter/flutter_export_environment.sh

evi-flutter-example/audio/ios/Assets/.gitkeep

Whitespace-only changes.
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import AVFoundation
2+
import Flutter
3+
import UIKit
4+
5+
public class AudioPlugin: NSObject, FlutterPlugin {
6+
private var soundPlayer: SoundPlayer
7+
private var microphone: Microphone
8+
9+
private var eventChannel: FlutterEventChannel?
10+
private var eventSink: FlutterEventSink?
11+
12+
private func sendError(_ message: String) {
13+
DispatchQueue.main.async {
14+
self.eventSink?([
15+
"type": "error",
16+
"message": message,
17+
])
18+
}
19+
}
20+
private func sendAudio(_ base64String: String) {
21+
DispatchQueue.main.async {
22+
self.eventSink?([
23+
"type": "audio",
24+
"data": base64String,
25+
])
26+
}
27+
}
28+
29+
public static func register(with registrar: FlutterPluginRegistrar) {
30+
let methodChannel = FlutterMethodChannel(
31+
name: "audio",
32+
binaryMessenger: registrar.messenger()
33+
)
34+
35+
let eventChannel = FlutterEventChannel(
36+
name: "audio/events",
37+
binaryMessenger: registrar.messenger()
38+
)
39+
40+
let instance = AudioPlugin()
41+
42+
registrar.addMethodCallDelegate(instance, channel: methodChannel)
43+
44+
eventChannel.setStreamHandler(instance)
45+
46+
instance.eventChannel = eventChannel
47+
}
48+
49+
override init() {
50+
self.microphone = Microphone()
51+
self.soundPlayer = SoundPlayer()
52+
53+
super.init()
54+
55+
self.soundPlayer.onError { [weak self] error in
56+
guard let self = self else { return }
57+
guard let eventSink = self.eventSink else { return }
58+
59+
switch error {
60+
case .invalidBase64String:
61+
sendError("Invalid base64 string")
62+
case .couldNotPlayAudio:
63+
sendError("Could not play audio")
64+
case .decodeError(let details):
65+
sendError(details)
66+
}
67+
}
68+
}
69+
70+
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
71+
switch call.method {
72+
case "getPermissions":
73+
Task {
74+
await getPermissions()
75+
}
76+
case "startRecording":
77+
do {
78+
try ensureInittedAudioSession()
79+
try microphone.startRecording(onBase64EncodedAudio: sendAudio)
80+
result(nil)
81+
} catch {
82+
result(
83+
FlutterError(
84+
code: "START_RECORDING_ERROR",
85+
message: error.localizedDescription,
86+
details: nil
87+
)
88+
)
89+
}
90+
91+
case "enqueueAudio":
92+
guard let base64String = call.arguments as? String else {
93+
result(
94+
FlutterError(
95+
code: "INVALID_ARGUMENTS",
96+
message: "Expected base64 string",
97+
details: nil
98+
))
99+
return
100+
}
101+
Task {
102+
do {
103+
try await soundPlayer.enqueueAudio(base64String)
104+
} catch {
105+
sendError(error.localizedDescription)
106+
}
107+
}
108+
result(nil)
109+
110+
case "stopPlayback":
111+
soundPlayer.stopPlayback()
112+
result(nil)
113+
114+
case "stopRecording":
115+
microphone.stopRecording()
116+
result(nil)
117+
118+
default:
119+
result(FlutterMethodNotImplemented)
120+
}
121+
}
122+
123+
private func getPermissions() async -> Bool {
124+
let audioSession = AVAudioSession.sharedInstance()
125+
switch audioSession.recordPermission {
126+
case .granted:
127+
return true
128+
case .denied:
129+
return false
130+
case .undetermined:
131+
return await withCheckedContinuation { continuation in
132+
audioSession.requestRecordPermission { granted in
133+
continuation.resume(returning: granted)
134+
}
135+
}
136+
@unknown default:
137+
sendError("Unknown permission state")
138+
return false
139+
}
140+
}
141+
142+
private var inittedAudioSession = false
143+
private func ensureInittedAudioSession() throws {
144+
if inittedAudioSession { return }
145+
146+
let audioSession = AVAudioSession.sharedInstance()
147+
try audioSession.setCategory(
148+
.playAndRecord,
149+
mode: .voiceChat,
150+
options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP]
151+
)
152+
try audioSession.setActive(true)
153+
inittedAudioSession = true
154+
}
155+
}
156+
157+
extension AudioPlugin: FlutterStreamHandler {
158+
public func onListen(
159+
withArguments arguments: Any?,
160+
eventSink events: @escaping FlutterEventSink
161+
) -> FlutterError? {
162+
self.eventSink = events
163+
return nil
164+
}
165+
166+
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
167+
self.eventSink = nil
168+
return nil
169+
}
170+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import AVFoundation
2+
import Foundation
3+
4+
public enum MicrophoneError: Error {
5+
case conversionFailed(details: String)
6+
}
7+
public class Microphone {
8+
public static let sampleRate: Double = 44100
9+
public static let isLinear16PCM: Bool = true
10+
// Linear16 PCM is a standard format well-supported by EVI (although you must send
11+
// a `session_settings` message to inform EVI of the sample rate). Because there is
12+
// a wide variance of the native format/ sample rate from input devices, we use the
13+
// AVAudioConverter API to convert the audio to this standard format in order to
14+
// remove all guesswork.
15+
private static let desiredInputFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: sampleRate, channels: 1, interleaved: false)!
16+
17+
public var audioEngine: AVAudioEngine
18+
private var inputNode: AVAudioInputNode
19+
private var isMuted: Bool = false
20+
private var onError: ((MicrophoneError) -> Void)?
21+
22+
public init() {
23+
self.isMuted = false
24+
self.audioEngine = AVAudioEngine()
25+
self.inputNode = audioEngine.inputNode
26+
27+
do {
28+
let outputNode: AVAudioOutputNode = audioEngine.outputNode
29+
let mainMixerNode: AVAudioMixerNode = audioEngine.mainMixerNode
30+
audioEngine.connect(mainMixerNode, to: outputNode, format: nil)
31+
32+
// Voice processing is a feature that can help reduce echo and background noise
33+
// It is very important for audio chat applications like EVI, because without
34+
// echo cancellation, EVI will hear its own output and attempt to respond to it.
35+
36+
// `setVoiceProcessingEnabled` should be enabled on *both* the input and output nodes
37+
// because it works by observing signals that are sent to the output node (the
38+
// speaker) and then "cancels" the echoes of those signals from what comes
39+
// back into the input node (the microphone).
40+
try self.inputNode.setVoiceProcessingEnabled(true)
41+
try outputNode.setVoiceProcessingEnabled(true)
42+
43+
if #available(iOS 17.0, *) {
44+
let duckingConfig = AVAudioVoiceProcessingOtherAudioDuckingConfiguration(enableAdvancedDucking: false, duckingLevel: .max)
45+
inputNode.voiceProcessingOtherAudioDuckingConfiguration = duckingConfig
46+
}
47+
} catch {
48+
print("Error setting voice processing: \(error)")
49+
return
50+
}
51+
}
52+
53+
public func onError(_ onError: @escaping (MicrophoneError) -> Void) {
54+
self.onError = onError
55+
}
56+
57+
public func mute() {
58+
self.isMuted = true
59+
}
60+
61+
public func unmute() {
62+
self.isMuted = false
63+
}
64+
65+
public func startRecording(onBase64EncodedAudio: @escaping (String) -> Void) throws {
66+
let nativeInputFormat = self.inputNode.inputFormat(forBus: 0)
67+
// The sample rate is "samples per second", so multiplying by 0.1 should get us chunks of about 100ms
68+
let inputBufferSize = UInt32(nativeInputFormat.sampleRate * 0.1)
69+
self.inputNode.installTap(onBus: 0, bufferSize: inputBufferSize, format: nativeInputFormat) { (buffer, time) in
70+
let convertedBuffer = AVAudioPCMBuffer(pcmFormat: Microphone.desiredInputFormat, frameCapacity: 1024)!
71+
72+
var error: NSError? = nil
73+
74+
if self.isMuted {
75+
// The standard behavior for muting is to send audio frames filled with empty data
76+
// (versus not sending anything during mute). This helps audio systems distinguish
77+
// between muted-but-still-active streams and streams that have become disconnected.
78+
let silence = Data(repeating: 0, count: Int(convertedBuffer.frameCapacity) * Int(convertedBuffer.format.streamDescription.pointee.mBytesPerFrame))
79+
onBase64EncodedAudio(silence.base64EncodedString())
80+
return
81+
}
82+
let inputAudioConverter = AVAudioConverter(from: nativeInputFormat, to: Microphone.desiredInputFormat)!
83+
let status = inputAudioConverter.convert(to: convertedBuffer, error: &error, withInputFrom: {inNumPackets, outStatus in
84+
outStatus.pointee = .haveData
85+
buffer.frameLength = inNumPackets
86+
return buffer
87+
})
88+
89+
if status == .haveData {
90+
let byteLength = Int(convertedBuffer.frameLength) * Int(convertedBuffer.format.streamDescription.pointee.mBytesPerFrame)
91+
let audioData = Data(bytes: convertedBuffer.audioBufferList.pointee.mBuffers.mData!, count: byteLength)
92+
let base64String = audioData.base64EncodedString()
93+
onBase64EncodedAudio(base64String)
94+
return
95+
}
96+
if error != nil {
97+
self.onError?(MicrophoneError.conversionFailed(details: error!.localizedDescription))
98+
return
99+
}
100+
self.onError?(MicrophoneError.conversionFailed(details: "Unexpected status during audio conversion: \(status)"))
101+
}
102+
103+
if (!audioEngine.isRunning) {
104+
try audioEngine.start()
105+
}
106+
}
107+
108+
public func stopRecording() {
109+
audioEngine.stop()
110+
self.inputNode.removeTap(onBus: 0)
111+
}
112+
}

0 commit comments

Comments
 (0)