package net.siteed.audiostream import android.media.AudioFormat import android.os.Build import java.io.File // New output configuration structure data class OutputConfig( val primary: PrimaryOutput = PrimaryOutput(), val compressed: CompressedOutput = CompressedOutput() ) { data class PrimaryOutput( val enabled: Boolean = true, val format: String = "wav" ) data class CompressedOutput( val enabled: Boolean = false, val format: String = "aac", val bitrate: Int = 128000, val preferRawStream: Boolean = false ) companion object { fun fromMap(map: Map?): OutputConfig { if (map == null) return OutputConfig() val primaryMap = map.getTypedMap("primary") { true } val compressedMap = map.getTypedMap("compressed") { true } val primary = PrimaryOutput( enabled = primaryMap.getBooleanOrDefault("enabled", true), format = primaryMap.getStringOrDefault("format", "wav") ) val compressed = CompressedOutput( enabled = compressedMap.getBooleanOrDefault("enabled", false), format = compressedMap.getStringOrDefault("format", "aac").lowercase(), bitrate = compressedMap.getNumberOrDefault("bitrate", 128000), preferRawStream = compressedMap.getBooleanOrDefault("preferRawStream", false) ) return OutputConfig(primary = primary, compressed = compressed) } } } /** * Opt-in configuration for the native direct-to-file AAC encoder. * Mirrors the TypeScript `CompressionConfig` and the iOS struct. * * When set, [AACFileWriter] writes a single AAC-LC / ADTS file using the * SAME PCM bytes read from [android.media.AudioRecord] — no second mic * capture, no JS bridge traffic for AAC data. */ data class CompressionConfig( val format: String = "aac", // only "aac" is supported val bitrate: Int, val container: String = "adts", // only "adts" is supported val outputDirectory: String? = null ) { companion object { const val MIN_BITRATE = 16_000 const val MAX_BITRATE = 192_000 const val VOICE_BITRATE_FLOOR = 24_000 private const val TAG = "CompressionConfig" fun fromMap(map: Map?, sampleRate: Int): Result { if (map == null) return Result.failure(IllegalArgumentException("compression map is null")) val format = (map["format"] as? String)?.lowercase() ?: "aac" if (format != "aac") { return Result.failure(IllegalArgumentException("Unsupported compression.format: $format. Only 'aac' is supported.")) } val container = (map["container"] as? String)?.lowercase() ?: "adts" if (container != "adts") { return Result.failure(IllegalArgumentException("Unsupported compression.container: $container. Only 'adts' is supported.")) } val bitrate = (map["bitrate"] as? Number)?.toInt() ?: return Result.failure(IllegalArgumentException("compression.bitrate is required")) if (bitrate < MIN_BITRATE || bitrate > MAX_BITRATE) { return Result.failure(IllegalArgumentException( "compression.bitrate=$bitrate out of range [$MIN_BITRATE..$MAX_BITRATE]" )) } if (AACFileWriter.adtsSampleRateIndex(sampleRate) < 0) { return Result.failure(IllegalArgumentException( "Unsupported compression sample rate: $sampleRate. Must be one of ${AACFileWriter.ADTS_SAMPLE_RATE_TABLE.toList()}." )) } if (bitrate < VOICE_BITRATE_FLOOR) { LogUtils.w(TAG, "compression.bitrate=$bitrate is below the recommended floor $VOICE_BITRATE_FLOOR for AAC-LC mono voice") } val outDir = (map["outputDirectory"] as? String)?.takeIf { it.isNotEmpty() } return Result.success( CompressionConfig( format = format, bitrate = bitrate, container = container, outputDirectory = outDir ) ) } } } data class RecordingConfig( val sampleRate: Int = Constants.DEFAULT_SAMPLE_RATE, val channels: Int = 1, val encoding: String = "pcm_16bit", val keepAwake: Boolean = true, val interval: Long = Constants.DEFAULT_INTERVAL, val intervalAnalysis: Long = Constants.DEFAULT_INTERVAL_ANALYSIS, val enableProcessing: Boolean = false, val segmentDurationMs: Int = 100, val showNotification: Boolean = false, val showWaveformInNotification: Boolean = false, val notification: NotificationConfig = NotificationConfig(), val features: Map = emptyMap(), val output: OutputConfig = OutputConfig(), val autoResumeAfterInterruption: Boolean = false, val outputDirectory: String? = null, val filename: String? = null, val deviceId: String? = null, val deviceDisconnectionBehavior: String? = null, val audioFocusStrategy: String? = null, val bufferDurationSeconds: Double? = null, /** * Opt-in native direct-to-file AAC encoder. When non-null, AAC frames * are written to disk by [AACFileWriter] from the same PCM buffer the * primary WAV writer and the base64 AudioData event use. AAC bytes * never cross the JS bridge. Takes precedence over [output.compressed] * (legacy path). */ val compression: CompressionConfig? = null, ) { companion object { fun fromMap(options: Map?): Result> { if (options == null) { val defaultConfig = RecordingConfig() val defaultFormat = AudioFormatInfo( format = AudioFormat.ENCODING_PCM_16BIT, mimeType = "audio/wav", fileExtension = "wav" ) return Result.success(Pair(defaultConfig, defaultFormat)) } // Extract features using type-safe helper val features = options.getTypedMap("features") { it is Boolean } // Parse notification config using type-safe helper val notificationMap = options.getTypedMap("notification") { true } val notificationConfig = NotificationConfig.fromMap(notificationMap) // Parse output config val outputMap = options.getTypedMap("output") { true } var outputConfig = OutputConfig.fromMap(outputMap) // Validate bitrate if compression is enabled if (outputConfig.compressed.enabled) { when { outputConfig.compressed.bitrate < 8000 -> return Result.failure( IllegalArgumentException("Bitrate must be at least 8000 bps") ) outputConfig.compressed.bitrate > 960000 -> return Result.failure( IllegalArgumentException("Bitrate cannot exceed 960000 bps") ) } } // Parse opt-in native `compression` (AAC direct-to-file). // Must be validated AFTER we know the recording sample rate (for // the ADTS sample-rate table check), so we defer to after // tempRecordingConfig is built — but we also need to disable the // legacy output.compressed path here if both are set, before // passing outputConfig to the config constructor. val compressionMap = options["compression"] as? Map if (compressionMap != null && outputConfig.compressed.enabled) { LogUtils.w( "RecordingConfig", "Both `compression` (new) and `output.compressed` (legacy) were set. The legacy path is disabled for this recording." ) outputConfig = outputConfig.copy( compressed = outputConfig.compressed.copy(enabled = false) ) } // Only validate directory if it's provided val outputDirectory = options["outputDirectory"] as? String if (outputDirectory != null) { // Clean up the directory path by removing file:// protocol and normalizing val cleanDirectory = outputDirectory .replace(Regex("^file://"), "") .trim('/') .replace("//", "/") val directory = File(cleanDirectory) if (!directory.exists()) { return Result.failure(IllegalArgumentException("Directory does not exist: $cleanDirectory")) } if (!directory.isDirectory) { return Result.failure(IllegalArgumentException("Path is not a directory: $cleanDirectory")) } if (!directory.canWrite()) { return Result.failure(IllegalArgumentException("Directory is not writable: $cleanDirectory")) } } // Get device-related settings val deviceId = options["deviceId"] as? String val deviceDisconnectionBehavior = options["deviceDisconnectionBehavior"] as? String // Get Android-specific settings val androidConfig = options["android"] as? Map val audioFocusStrategy = androidConfig?.get("audioFocusStrategy") as? String // Initialize the recording configuration with cleaned directory path val tempRecordingConfig = RecordingConfig( sampleRate = options.getNumberOrDefault("sampleRate", Constants.DEFAULT_SAMPLE_RATE), channels = options.getNumberOrDefault("channels", 1), encoding = options.getStringOrDefault("encoding", "pcm_16bit"), keepAwake = options.getBooleanOrDefault("keepAwake", true), // Enforce minimum intervals to prevent excessive CPU usage interval = maxOf(Constants.MIN_INTERVAL, options.getNumberOrDefault("interval", Constants.DEFAULT_INTERVAL)), intervalAnalysis = maxOf(Constants.MIN_INTERVAL, options.getNumberOrDefault("intervalAnalysis", Constants.DEFAULT_INTERVAL_ANALYSIS)), enableProcessing = options.getBooleanOrDefault("enableProcessing", false), segmentDurationMs = options.getNumberOrDefault("segmentDurationMs", 100), showNotification = options.getBooleanOrDefault("showNotification", false), showWaveformInNotification = options.getBooleanOrDefault("showWaveformInNotification", false), notification = notificationConfig, features = features, output = outputConfig, autoResumeAfterInterruption = options.getBooleanOrDefault("autoResumeAfterInterruption", false), outputDirectory = outputDirectory?.let { it.replace(Regex("^file://"), "") .trim('/') .replace("//", "/") }, filename = options["filename"] as? String, deviceId = deviceId, deviceDisconnectionBehavior = deviceDisconnectionBehavior, audioFocusStrategy = audioFocusStrategy, bufferDurationSeconds = (options["bufferDurationSeconds"] as? Number)?.toDouble(), ) // Validate sample rate and channels if (tempRecordingConfig.sampleRate !in listOf(16000, 44100, 48000)) { return Result.failure( IllegalArgumentException("Sample rate must be one of 16000, 44100, or 48000 Hz") ) } if (tempRecordingConfig.channels !in 1..2) { return Result.failure( IllegalArgumentException("Channels must be either 1 (Mono) or 2 (Stereo)") ) } // Now that the sample rate is validated, parse the optional native // `compression` config. If present, attach it to the final config; // any validation failure (bad bitrate / container / sample rate) // aborts startRecording with a clear error. val finalConfigWithCompression = if (compressionMap != null) { val parsed = CompressionConfig.fromMap(compressionMap, tempRecordingConfig.sampleRate) if (parsed.isFailure) { return Result.failure(parsed.exceptionOrNull() ?: IllegalArgumentException("Invalid compression config")) } tempRecordingConfig.copy(compression = parsed.getOrNull()) } else { tempRecordingConfig } // Set encoding and file extension val audioFormatInfo = when (tempRecordingConfig.encoding) { "pcm_8bit" -> AudioFormatInfo( format = AudioFormat.ENCODING_PCM_8BIT, mimeType = "audio/wav", fileExtension = "wav" ) "pcm_16bit" -> AudioFormatInfo( format = AudioFormat.ENCODING_PCM_16BIT, mimeType = "audio/wav", fileExtension = "wav" ) "pcm_32bit" -> AudioFormatInfo( format = AudioFormat.ENCODING_PCM_FLOAT, mimeType = "audio/wav", fileExtension = "wav" ) "opus" -> { if (Build.VERSION.SDK_INT < 29) { return Result.failure( IllegalArgumentException("Opus encoding not supported on this Android version.") ) } AudioFormatInfo( format = if (Build.VERSION.SDK_INT >= 29) 20 else AudioFormat.ENCODING_DEFAULT, // 20 is ENCODING_OPUS mimeType = "audio/opus", fileExtension = "opus" ) } "aac_lc" -> AudioFormatInfo( format = AudioFormat.ENCODING_AAC_LC, mimeType = "audio/aac", fileExtension = "aac" ) else -> AudioFormatInfo( format = AudioFormat.ENCODING_DEFAULT, mimeType = "audio/wav", fileExtension = "wav" ) } return Result.success(Pair(finalConfigWithCompression, audioFormatInfo)) } } } // Extension functions for type-safe map access private inline fun Map.getTypedMap( key: String, predicate: (Any?) -> Boolean ): Map { return (this[key] as? Map<*, *>)?.mapNotNull { (k, v) -> if (k is String && predicate(v)) { k to (v as T) } else null }?.toMap() ?: emptyMap() } private fun Map.getStringOrDefault(key: String, default: String): String { return this[key] as? String ?: default } private fun Map.getBooleanOrDefault(key: String, default: Boolean): Boolean { return this[key] as? Boolean ?: default } private fun Map.getNumberOrDefault(key: String, default: Int): Int { return (this[key] as? Number)?.toInt() ?: default } private fun Map.getNumberOrDefault(key: String, default: Long): Long { return (this[key] as? Number)?.toLong() ?: default } private fun Map.getNumberOrDefault(key: String, default: Double): Double { return (this[key] as? Number)?.toDouble() ?: default } data class AudioFormatInfo( val format: Int, val mimeType: String, val fileExtension: String )