This document describes the current Apple Watch ↔ iOS ↔ Flutter audio pipeline. The watch records audio, resamples to 16kHz, buffers ~1.5s chunks, and transmits reliably to the paired iPhone using WatchConnectivity with an adaptive strategy. Pigeon provides a type-safe bridge between iOS native and Flutter.
┌─────────────────┐ WatchConnectivity ┌─────────────────┐ Pigeon Bridge ┌─────────────────┐
│ Apple Watch │ ──────────────────────► │ iOS App │ ──────────────────► │ Flutter App │
│ │ │ │ │ │
│ • Audio Engine │ │ • AppDelegate │ │ • Watch Home │
│ • Real-time │ │ • Message │ │ • Audio Chunks │
│ Streaming │ │ Handling │ │ • WAV File │
│ • Auto-resample │ │ • Chunked Data │ │ Creation │
│ to 16kHz │ │ • 16kHz Output │ │ • 16kHz Audio │
└─────────────────┘ └─────────────────┘ └─────────────────┘
- Protocol:
WCSessionDelegate - Data Format: Dictionary with method calls and audio data
- Chunking (Updated):
- Capture with a 512-frame tap for low-latency processing
- Aggregate on-watch into ~1.5-second chunks to reduce send frequency
- Foreground/reachable:
sendMessage(real-time) - Background/unreachable:
transferUserInfo(opportunistic)
- Sample Rate: 16kHz (resampled from native)
- Protocol: Generated Pigeon APIs (
WatchRecorderHostAPI,WatchRecorderFlutterAPI) - Host Impl (Updated):
RecorderHostApiImpl(separate file) handles start/stop, permissions, and chunk forwarding - Data Format:
FlutterStandardTypedDatafor binary audio - Delivery: iOS forwards chunks via
onAudioChunk(...); full buffers viaonAudioData(...)when applicable
WatchAudioRecorderViewModel.swift # Main audio recording (renamed, no counter)
ContentView.swift # Record/stop UI
Info.plist # Mic permission + UIBackgroundModes: audio
omiwatchApp.swift # App entry point
AppDelegate.swift # WatchConnectivity handling
FlutterCommunicator.g.swift # Generated Pigeon bridge
RecorderHostApiImpl.swift # Host API implementation
watch_home.dart # UI and audio data handling
flutter_communicator.g.dart # Generated Pigeon bridge
// Host API (Flutter → iOS → Watch)
@HostApi()
abstract class WatchRecorderHostAPI {
void startRecording();
void stopRecording();
void sendAudioChunk(Uint8List audioChunk, int chunkIndex, bool isLast, double sampleRate);
bool isWatchPaired();
bool isWatchReachable();
bool isWatchSessionSupported();
bool isWatchAppInstalled();
void requestWatchMicrophonePermission();
void requestMainAppMicrophonePermission();
bool checkMainAppMicrophonePermission();
}
// Flutter API (iOS → Flutter)
@FlutterApi()
abstract class WatchRecorderFlutterAPI {
void onRecordingStarted();
void onRecordingStopped();
void onAudioChunk(Uint8List audioChunk, int chunkIndex, bool isLast, double sampleRate);
void onAudioData(Uint8List audioData);
void onRecordingError(String error);
void onMicrophonePermissionResult(bool granted);
void onMainAppMicrophonePermissionResult(bool granted);
}audioEngine = AVAudioEngine()
inputNode = audioEngine?.inputNode
let inputFormat = inputNode?.inputFormat(forBus: 0)
detectedSampleRate = inputFormat?.sampleRate ?? 0
targetFormat = AVAudioFormat(standardFormatWithSampleRate: 16000.0, channels: 1)
audioConverter = AVAudioConverter(from: inputFormat!, to: targetFormat!)
inputNode?.installTap(onBus: 0, bufferSize: 512, format: inputFormat) { [weak self] buffer, _ in
self?.processAudioBuffer(buffer)
}
try audioEngine?.start()private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
// Resample → 16kHz and append to chunkBuffer (Data)
// When bufferDuration (~1.5s) elapses, sendBufferedAudioChunk()
bufferAndSendAudioData(byteData)
}if session.isReachable {
session.sendMessage(messageData, replyHandler: nil) { error in
self.session.transferUserInfo(messageData)
}
} else {
session.transferUserInfo(messageData)
}// Foreground: session(_:didReceiveMessage:)
// Background: session(_:didReceiveUserInfo:)
private func handleAudioChunk(_ message: [String: Any]) {
guard isRecordingActive else { return }
audioChunks[chunkIndex] = (audioChunk, sampleRate)
flutterWatchAPI?.onAudioChunk(...)
if isLast { reassembleAndSendAudioData() }
}void _reassembleAudioData() {
// Sort chunks by index and combine
final sortedChunks = _audioChunks.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
final bytesBuilder = BytesBuilder();
for (final entry in sortedChunks) {
final (chunkData, _) = entry.value;
bytesBuilder.add(chunkData);
}
final combinedData = bytesBuilder.toBytes();
_saveAudioFile();
}Uint8List _createWavFile(Uint8List pcmData) {
final int sampleRate = _sampleRate.toInt(); // Use actual sample rate (48kHz)
const int bitsPerSample = 16;
const int numChannels = 1; // Mono
// Create RIFF header with proper metadata
// RIFF, Format, Data chunks
// Sample rate, bit depth, channels, etc.
return builder.toBytes();
}<key>NSMicrophoneUsageDescription</key>
<string>This app needs access to the microphone to record audio from your Apple Watch.</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array><key>NSMicrophoneUsageDescription</key>
<string>We need access to your microphone so you can share audio explanations of Bugs you find.</string>if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
// Setup Pigeon bridge
let api: WatchRecorderHostAPI = RecorderHostApiImpl(session: session!)
WatchRecorderHostAPISetup.setUp(binaryMessenger: controller!.binaryMessenger, api: api)
flutterWatchAPI = WatchRecorderFlutterAPI(binaryMessenger: controller!.binaryMessenger)
}init(session: WCSession = .default) {
self.session = session
super.init()
self.session.delegate = self
session.activate()
}dart run pigeon --input watch_interface.dart- Input Sample Rate: Variable (22,050 Hz - 48,000 Hz depending on watch model)
- Output Sample Rate: 16,000 Hz (resampled for consistency and speech recognition)
- Bit Depth: 16-bit PCM
- Channels: 1 (Mono)
- Format: Linear PCM (LPCM)
- Byte Order: Little-endian
- Note:
setPreferredSampleRateunavailable on watchOS - resampling handled viaAVAudioConverter
- Tap Buffer Size: 512 frames (low latency)
- Transmission Chunk Size: ~1.5s of 16kHz mono PCM (~48–56KB typical)
- Adaptive Transfer:
sendMessage(foreground) →transferUserInfo(background)
- Microphone Permission: Checked before recording
- Audio Session: Properly configured with error handling
- Data Transmission: Fallback mechanisms for failed chunks
- File Creation: Validation of audio data before WAV creation
Symptoms: Audio recording completes but no data received Causes:
- Microphone permissions not granted
- Watch not paired with iPhone
- Audio session setup failure Solutions:
- Check watch microphone permissions in Settings
- Ensure watch is paired and reachable
- Verify audio session initialization logs
Symptoms: WatchConnectivity transmission fails Status: ✅ RESOLVED - Now using chunked transmission Prevention: Keep chunks under 64KB each
Symptoms: Audio plays back too slowly or distorted Causes:
- Incorrect sample rate in WAV header
- Buffer processing issues Status: ✅ RESOLVED - Now uses correct 48kHz sample rate
Symptoms: Incomplete audio file Causes:
- WatchConnectivity message loss
- Buffer overflow Solutions:
- Check WatchConnectivity reachability
- Reduce buffer size if needed
- Add chunk sequence validation
"Audio session configured successfully"
"Input format: <AVAudioFormat...>"
"Sample rate: 48000.0"
"Audio streaming started successfully"
"Sent audio chunk X with Y bytes, rate: 48000Hz"
"Received sendAudioChunk message from watch"
"Received audio chunk X, size: Y bytes, isLast: Z, rate: RHz"
"Audio chunk X sent to Flutter - Success"
"Reassembling audio data from C chunks"
"Flutter: onAudioChunk callback called with X bytes, chunk: Y, isLast: Z, rate: RHz"
"Reassembling audio data from C chunks at RHz"
"Created WAV file with sample rate: RHz"
"WAV file saved to: /path/to/file.wav"
- End-to-end Latency: ~1.5–2.0s (due to on-watch aggregation)
- Chunk Size: ~48–56KB per chunk
- Sample Rate: 48kHz capture → 16kHz transmit
- Memory Usage: Minimal (chunks processed immediately)
- Battery Impact: Low (optimized buffer sizes)
- Compression: Could add audio compression for smaller files
- Quality Settings: Could allow sample rate selection
- Background Processing: Could add background audio processing
- Error Recovery: Could add automatic retry for failed chunks
- User taps record button → Watch starts audio streaming
- Audio captured in buffers → 512-frame chunks processed
- Chunks sent via WatchConnectivity → iOS app receives data
- iOS forwards to Flutter → Real-time chunk delivery
- Flutter reassembles data → Complete PCM audio created
- WAV header added → Proper audio file format
- File saved locally → Ready for playback/upload
✅ Real-time streaming - No more "payload too large" errors ✅ Consistent sample rate - Always 16kHz regardless of watch model ✅ Automatic resampling - Handles variable hardware sample rates ✅ Low latency - Audio data flows immediately ✅ Error resilient - Graceful handling of network issues ✅ Memory efficient - Chunks processed without accumulation ✅ Platform optimized - Leverages native audio APIs
- Audio compression (Opus/FLAC) for smaller files
- Background recording when app is not active
- Multiple quality options (16kHz/48kHz selection)
- Audio effects (noise reduction, echo cancellation)
- Offline storage with automatic upload when connected
- Voice activity detection to reduce unnecessary data
Last Updated: December 2024 System Status: ✅ FULLY OPERATIONAL Architecture: Real-time streaming with chunked transmission