diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 2891a33..05cbd2c 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -71,8 +71,10 @@ kotlin { implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.viewmodel.compose) implementation(libs.koin.core) implementation(libs.koin.compose) + implementation(libs.koin.compose.viewmodel) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) } diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index e688bcc..04c9b28 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -17,6 +17,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" + android:enableOnBackInvokedCallback="true" android:theme="@android:style/Theme.Material.Light.NoActionBar"> { DefaultPermissionService() } - single { MeasurementService(audioSource = get()) } + module { + single { + DefaultMeasurementService(audioSource = get(), logger = get()) + } }, + defaultPermissionModule, - platformPermissionModule() + platformPermissionModule(), + + homeModule, + requestPermissionModule, + measurementModule, ) createEagerInstances() } diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/NoiseCaptureApp.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/NoiseCaptureApp.kt index 450424d..ee7f54e 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/NoiseCaptureApp.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/NoiseCaptureApp.kt @@ -1,8 +1,5 @@ package org.noiseplanet.noisecapture -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.Scaffold @@ -14,13 +11,12 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import org.koin.compose.koinInject import org.noiseplanet.noisecapture.ui.AppBar -import org.noiseplanet.noisecapture.ui.NavigationRoute -import org.noiseplanet.noisecapture.ui.screens.HomeScreen -import org.noiseplanet.noisecapture.ui.screens.MeasurementScreen -import org.noiseplanet.noisecapture.ui.screens.PlatformInfoScreen -import org.noiseplanet.noisecapture.ui.screens.RequestPermissionScreen +import org.noiseplanet.noisecapture.ui.features.home.HomeScreen +import org.noiseplanet.noisecapture.ui.features.measurement.MeasurementScreen +import org.noiseplanet.noisecapture.ui.features.permission.RequestPermissionScreen +import org.noiseplanet.noisecapture.ui.navigation.Route +import org.noiseplanet.noisecapture.ui.navigation.Transitions /** @@ -28,14 +24,13 @@ import org.noiseplanet.noisecapture.ui.screens.RequestPermissionScreen * Currently handles the navigation stack, and navigation bar management. */ @Composable -fun NoiseCaptureApp( - navController: NavHostController = rememberNavController(), -) { +fun NoiseCaptureApp() { + val navController: NavHostController = rememberNavController() // Get current navigation back stack entry val backStackEntry by navController.currentBackStackEntryAsState() // Get the name of the current screen - val currentScreen = NavigationRoute.valueOf( - backStackEntry?.destination?.route ?: NavigationRoute.Home.name + val currentScreen = Route.valueOf( + backStackEntry?.destination?.route ?: Route.Home.name ) Scaffold( @@ -47,56 +42,31 @@ fun NoiseCaptureApp( ) } ) { innerPadding -> - // TODO: Configure NavHost in a separate file - // TODO: Use ease out curve for slide transitions // TODO: Handle swipe back gestures on iOS -> encapsulate UINavigationController? - // TODO: Handle predictive back gestures on Android NavHost( navController = navController, - startDestination = NavigationRoute.Home.name, - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, tween(300)) - }, - exitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, tween(300)) - }, + startDestination = Route.Home.name, + enterTransition = Transitions.enterTransition, + exitTransition = Transitions.exitTransition, + popEnterTransition = Transitions.popEnterTransition, + popExitTransition = Transitions.popExitTransition, modifier = Modifier .fillMaxSize() .padding(innerPadding) ) { - composable(route = NavigationRoute.Home.name) { - HomeScreen( - onClick = { - // TODO: Silently check for permissions and bypass this step if they are already all granted - navController.navigate(NavigationRoute.RequestPermission.name) - }, - ) - } - composable(route = NavigationRoute.PlatformInfo.name) { - PlatformInfoScreen( - modifier = Modifier.fillMaxHeight() - ) + composable(route = Route.Home.name) { + // TODO: Silently check for permissions and bypass this step if they are already all granted + HomeScreen(navigationController = navController) } - composable(route = NavigationRoute.RequestPermission.name) { + composable(route = Route.RequestPermission.name) { RequestPermissionScreen( onClickNextButton = { - navController.navigate(NavigationRoute.Measurement.name) + navController.navigate(Route.Measurement.name) } ) } - composable(route = NavigationRoute.Measurement.name) { - // TODO: Decide of a standard for screens architecture: - // - class or compose function as root? - // - Inject dependencies in constructor or via Koin factories? - // - What should be the package structure? - MeasurementScreen(measurementService = koinInject()) - .Content() + composable(route = Route.Measurement.name) { + MeasurementScreen() } } } diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/FrequencyBand.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/FrequencyBand.kt new file mode 100644 index 0000000..277cdb8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/FrequencyBand.kt @@ -0,0 +1,76 @@ +package org.noiseplanet.noisecapture.audio.signal + +import kotlin.math.pow + +data class FrequencyBand( + val minFrequency: Double, + val midFrequency: Double, + val maxFrequency: Double, + var spl: Double, +) { + + enum class BaseMethod { + B10, + B2 + } + + companion object { + + /** + * Create (third-)octave array from the specified parameters (without spl values) + * + * @param firstFrequencyBand First frequency band (Hz) + * @param lastFrequencyBand Last frequency band (Hz) + * @param base Octave base 2 or 10 + * @param bandDivision Octave bands division (defaults to 3 for third octaves) + */ + fun emptyFrequencyBands( + firstFrequencyBand: Double, + lastFrequencyBand: Double, + base: BaseMethod = BaseMethod.B10, + bandDivision: Double = 3.0, + ): Array { + val g = when (base) { + BaseMethod.B10 -> 10.0.pow(3.0 / 10.0) + BaseMethod.B2 -> 2.0 + } + val firstBandIndex = getBandIndexByFrequency(firstFrequencyBand, g, bandDivision) + val lastBandIndex = getBandIndexByFrequency(lastFrequencyBand, g, bandDivision) + return Array(lastBandIndex - firstBandIndex) { bandIndex -> + val (fMin, fMid, fMax) = getBands(bandIndex + firstBandIndex, g, bandDivision) + FrequencyBand(fMin, fMid, fMax, 0.0) + } + } + + private fun getBands( + bandIndex: Int, + g: Double, + bandDivision: Double, + ): Triple { + val fMid = g.pow(bandIndex / bandDivision) * 1000.0 + val fMax = g.pow(1.0 / (2.0 * bandDivision)) * fMid + val fMin = g.pow(-1.0 / (2.0 * bandDivision)) * fMid + return Triple(fMin, fMid, fMax) + } + + private fun getBandIndexByFrequency( + targetFrequency: Double, + g: Double, + bandDivision: Double, + ): Int { + var frequencyBandIndex = 0 + var (fMin, fMid, fMax) = getBands(frequencyBandIndex, g, bandDivision) + while (!(fMin < targetFrequency && targetFrequency < fMax)) { + if (targetFrequency < fMin) { + frequencyBandIndex -= 1 + } else if (targetFrequency > fMax) { + frequencyBandIndex += 1 + } + val bandInfo = getBands(frequencyBandIndex, g, bandDivision) + fMin = bandInfo.first + fMax = bandInfo.third + } + return frequencyBandIndex + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/LevelDisplayWeightedDecay.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/LevelDisplayWeightedDecay.kt index 5c86a91..ae22712 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/LevelDisplayWeightedDecay.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/LevelDisplayWeightedDecay.kt @@ -9,15 +9,23 @@ const val SLOW_DECAY_RATE = -4.3 /** * IEC 61672-1 standard for displayed sound level decay + * + * TODO: Document parameters */ -class LevelDisplayWeightedDecay(decibelDecayPerSecond: Double, newValueTimeInterval: Double) { +class LevelDisplayWeightedDecay( + decibelDecayPerSecond: Double, + newValueTimeInterval: Double, +) { - val timeWeight = 10.0.pow(decibelDecayPerSecond * newValueTimeInterval / 10.0) - var timeIntegration = 0.0 + private val timeWeight = 10.0.pow(decibelDecayPerSecond * newValueTimeInterval / 10.0) + private var timeIntegration = 0.0 + /** + * TODO: add documentation + */ fun getWeightedValue(newValue: Double): Double { - timeIntegration = - timeIntegration * timeWeight + 10.0.pow(newValue / 10.0) * (1 - timeWeight) + timeIntegration = timeIntegration * timeWeight + + 10.0.pow(newValue / 10.0) * (1 - timeWeight) return 10 * log10(timeIntegration) } diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/SpectrumChannel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/SpectrumChannel.kt index f1fcf5b..5a97b55 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/SpectrumChannel.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/SpectrumChannel.kt @@ -2,10 +2,13 @@ package org.noiseplanet.noisecapture.audio.signal import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import org.noiseplanet.noisecapture.audio.signal.filter.BiquadFilter +import org.noiseplanet.noisecapture.audio.signal.filter.DigitalFilter import kotlin.math.pow /** * Digital filtering of audio samples + * * @author Nicolas Fortin, Université Gustave Eiffel * @author Valentin Le Bescond, Université Gustave Eiffel * @link https://github.com/SonoMKR/sonomkr-core/blob/master/src/spectrumchannel.cpp diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/WindowAnalysis.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/WindowAnalysis.kt deleted file mode 100644 index c150aba..0000000 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/WindowAnalysis.kt +++ /dev/null @@ -1,319 +0,0 @@ -package org.noiseplanet.noisecapture.audio.signal - -import kotlin.math.PI -import kotlin.math.ceil -import kotlin.math.cos -import kotlin.math.floor -import kotlin.math.log10 -import kotlin.math.max -import kotlin.math.min -import kotlin.math.pow -import kotlin.math.sqrt - -/** - * Computation of STFT (Short Time Fourier Transform) - * @sampleRate Sample rate to compute epoch - * @windowSize Size of the window - * @windowHop Run a new analysis each windowHop samples - */ -class WindowAnalysis( - val sampleRate: Int, - val windowSize: Int, - val windowHop: Int, - private val applyHannWindow: Boolean = true -) { - val circularSamplesBuffer = FloatArray(windowSize) - var circularBufferCursor = 0 - var samplesUntilWindow = windowSize - val bluestein = if(nextPowerOfTwo(windowSize)!=windowSize) BluesteinFloat(windowSize) else null - val hannWindow: FloatArray? = when (applyHannWindow) { - true -> - FloatArray(windowSize) { - (0.5 * (1 - cos(2 * PI * it / (windowSize - 1)))).toFloat() - } - else -> null - } - //Windowing correction factors - // [1] F. J. Harris, “On the use of windows for harmonic analysis with the discrete fourier - // transform,”Proceedings of the IEEE, vol. 66, no. 1, pp. 51–83, Jan. 1978. - val windowCorrectionFactor = when (applyHannWindow) { - true -> 0.375 - else -> 1.0 - } - - init { - require(windowHop > 0) { - "Window hop must be superior than 0" - } - } - - /** - * Process the provided samples and run a STFFT analysis when a window is complete - */ - fun pushSamples( - epoch: Long, - samples: FloatArray, - processedWindows: MutableList? = null, - ): Sequence = sequence { - var processed = 0 - while (processed < samples.size) { - var toFetch = min(samples.size - processed, samplesUntilWindow) - // fill the circular buffer - while (toFetch > 0) { - val copySize = min(circularSamplesBuffer.size - circularBufferCursor, toFetch) - samples.copyInto( - circularSamplesBuffer, - circularBufferCursor, - processed, - processed + copySize - ) - circularBufferCursor += copySize - processed += copySize - toFetch -= copySize - samplesUntilWindow -= copySize - if (circularBufferCursor == circularSamplesBuffer.size) { - circularBufferCursor = 0 - } - } - if (samplesUntilWindow == 0) { - // window complete push it - val windowSamples = FloatArray(windowSize) - circularSamplesBuffer.copyInto( - windowSamples, - windowSize - circularBufferCursor, - 0, - circularBufferCursor - ) - circularSamplesBuffer.copyInto( - windowSamples, - 0, - circularBufferCursor, - circularSamplesBuffer.size - ) - // apply window function - if (hannWindow != null) { - for (i in windowSamples.indices) { - windowSamples[i] *= hannWindow[i] - } - } - val window = Window( - (epoch - ((samples.size - processed) / sampleRate.toDouble()) * 1000.0).toLong(), - windowSamples - ) - yield(processWindow(window)) - processedWindows?.add(window) - samplesUntilWindow = windowHop - } - } - } - - fun reconstructOriginalSignal(processedWindows: List): FloatArray { - val sum = FloatArray(processedWindows.size + processedWindows.size * windowHop) - for (i in processedWindows.indices) { - for (j in 0..Filling the FFT Input Buffer - */ - private fun processWindow(window: Window): SpectrumData { - return SpectrumData(window.epoch, processWindowFloat(window), sampleRate) - } - - fun processWindowFloat(window: Window) : FloatArray { - require(window.samples.size == windowSize) - val fr = (bluestein?.fft(window.samples) ?: realFFTFloat(window.samples)) - val vRef = (((windowSize*windowSize)/2.0)*windowCorrectionFactor).toFloat() - return FloatArray(fr.size / 2) { i: Int -> 10 * log10((fr[(i*2)+1]*fr[(i*2)+1]) /vRef) } - } - - fun processWindowDouble(window: Window): DoubleArray { - val fftWindowSize = nextPowerOfTwo(windowSize) - val fftWindow = DoubleArray(fftWindowSize) - val startIndex = windowSize / 2 - for (i in startIndex.. { - val fMid = g.pow(bandIndex / bandDivision) * 1000.0 - val fMax = g.pow(1.0 / (2.0 * bandDivision)) * fMid - val fMin = g.pow(-1.0 / (2.0 * bandDivision)) * fMid - return Triple(fMin, fMid, fMax) - } - - private fun getBandIndexByFrequency( - targetFrequency: Double, - g: Double, - bandDivision: Double, - ): Int { - var frequencyBandIndex = 0 - var (fMin, fMid, fMax) = getBands(frequencyBandIndex, g, bandDivision) - while (!(fMin < targetFrequency && targetFrequency < fMax)) { - if (targetFrequency < fMin) { - frequencyBandIndex -= 1 - } else if (targetFrequency > fMax) { - frequencyBandIndex += 1 - } - val bandInfo = getBands(frequencyBandIndex, g, bandDivision) - fMin = bandInfo.first - fMax = bandInfo.third - } - return frequencyBandIndex - } - - /** - * Create (third-)octave array from the specified parameters (without spl values) - */ - fun emptyFrequencyBands(firstFrequencyBand : Double, - lastFrequencyBand : Double, base : BaseMethod = BaseMethod.B10, - bandDivision : Double = 3.0) : Array { - val g = when (base) { - BaseMethod.B10 -> 10.0.pow(3.0 / 10.0) - BaseMethod.B2 -> 2.0 - } - val firstBandIndex = getBandIndexByFrequency(firstFrequencyBand, g, bandDivision) - val lastBandIndex = getBandIndexByFrequency(lastFrequencyBand, g, bandDivision) - return Array(lastBandIndex - firstBandIndex) { bandIndex -> - val (fMin, fMid, fMax) = getBands(bandIndex + firstBandIndex, g, bandDivision) - FrequencyBand(fMin, fMid, fMax, 0.0) - } - } - } - - /** - * @see ref - * Class 0 filter is 0.15 dB error according to IEC 61260 - * @sampleRate sample rate - * @firstFrequencyBand Skip bands up to specified frequency - * @lastFrequencyBand Skip bands higher than this frequency - * @base Octave base 10 or base 2 - * @octaveWindow Rectangular association of frequency band or fractional close to done by a filter - */ - @Suppress("NestedBlockDepth") - fun thirdOctaveProcessing( - firstFrequencyBand: Double, - lastFrequencyBand: Double, - base: BaseMethod = BaseMethod.B10, - bandDivision: Double = 3.0, - octaveWindow: OctaveWindow = OctaveWindow.FRACTIONAL, - ): Array { - val freqByCell: Double = (spectrum.size.toDouble() * 2) / sampleRate - val thirdOctave = - emptyFrequencyBands(firstFrequencyBand, lastFrequencyBand, base, bandDivision) - - if (octaveWindow == OctaveWindow.FRACTIONAL) { - for (band in thirdOctave) { - for (cellIndex in spectrum.indices) { - val f = (cellIndex + 1) / freqByCell - val division = - (f / band.midFrequency - band.midFrequency / f) * 1.507 * bandDivision - val cellGain = sqrt(1.0 / (1.0 + division.pow(6))) - val fg = 10.0.pow(spectrum[cellIndex] / 10.0) * cellGain - if (fg.isFinite()) { - band.spl += fg - } - } - } - for (band in thirdOctave) { - band.spl = 10 * log10(band.spl) - } - } else { - for (band in thirdOctave) { - val minCell = max(0, floor(band.minFrequency * freqByCell).toInt()) - val maxCell = min(spectrum.size, ceil(band.maxFrequency * freqByCell).toInt()) - var rms = 0.0 - for (cellIndex in minCell.. val realIndex = index * 2 val imIndex = index * 2 + 1 diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/BluesteinFloat.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/bluestein/BluesteinFloat.kt similarity index 75% rename from composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/BluesteinFloat.kt rename to composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/bluestein/BluesteinFloat.kt index ea51181..048965b 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/BluesteinFloat.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/bluestein/BluesteinFloat.kt @@ -1,5 +1,8 @@ -package org.noiseplanet.noisecapture.audio.signal +package org.noiseplanet.noisecapture.audio.signal.bluestein +import org.noiseplanet.noisecapture.audio.signal.fft.fftFloat +import org.noiseplanet.noisecapture.audio.signal.fft.iFFTFloat +import org.noiseplanet.noisecapture.audio.signal.fft.nextPowerOfTwo import kotlin.math.PI import kotlin.math.atan import kotlin.math.cos @@ -32,6 +35,7 @@ class BluesteinFloat(private val windowLength: Int) { @Suppress("TooManyFunctions") data class Complex(val real: Float, val imag: Float) { + operator fun plus(other: Complex) = Complex(real + other.real, imag + other.imag) operator fun minus(other: Complex) = Complex(real - other.real, imag - other.imag) operator fun times(other: Complex) = Complex( @@ -110,7 +114,7 @@ class BluesteinFloat(private val windowLength: Int) { fftFloat(ichirp.size / 2, ichirp) } - fun fft(x : FloatArray) : FloatArray { + fun fft(x: FloatArray): FloatArray { val inputIm = x.size == windowLength * 2 val xp = (0.. if (i < n) { @@ -120,35 +124,41 @@ class BluesteinFloat(private val windowLength: Int) { val c = Complex( x[realIndex], if (inputIm) x[imIndex] else 0F - ) * a.pow(-i) * Complex(chirp[chirpOffset + realIndex], chirp[chirpOffset + imIndex]) + ) * a.pow(-i) * Complex( + chirp[chirpOffset + realIndex], + chirp[chirpOffset + imIndex] + ) realImagArray[index * 2] = c.real realImagArray[index * 2 + 1] = c.imag } realImagArray } - fftFloat(xp.size/2, xp) - val r = (0..< n2).foldIndexed(FloatArray(n2*2)) { - index, realImagArray, i -> - val realIndex = index*2 - val imIndex = index*2+1 - val c = Complex(xp[realIndex], xp[imIndex]) * Complex(ichirp[realIndex], ichirp[imIndex]) - realImagArray[index*2] = c.real - realImagArray[index*2+1] = c.imag + fftFloat(xp.size / 2, xp) + val r = (0.. + val realIndex = index * 2 + val imIndex = index * 2 + 1 + val c = + Complex(xp[realIndex], xp[imIndex]) * Complex(ichirp[realIndex], ichirp[imIndex]) + realImagArray[index * 2] = c.real + realImagArray[index * 2 + 1] = c.imag realImagArray } - iFFTFloat(r.size/2, r) - return (n-1..< m+n-1).foldIndexed(FloatArray(if(inputIm) windowLength*2 else windowLength)) { - index, realImagArray, i -> - val realIndex = i*2 - val imIndex = i*2+1 - val c = Complex(r[realIndex], r[imIndex]) * Complex(chirp[realIndex], chirp[imIndex]) - if(inputIm) { - realImagArray[index * 2] = c.real - realImagArray[index * 2 + 1] = c.imag - } else { - realImagArray[index] = c.real + iFFTFloat(r.size / 2, r) + return (n - 1.. + val realIndex = i * 2 + val imIndex = i * 2 + 1 + val c = + Complex(r[realIndex], r[imIndex]) * Complex(chirp[realIndex], chirp[imIndex]) + if (inputIm) { + realImagArray[index * 2] = c.real + realImagArray[index * 2 + 1] = c.imag + } else { + realImagArray[index] = c.real + } + realImagArray } - realImagArray - } } } diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/fft.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/fft/fft.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/fft.kt rename to composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/fft/fft.kt index 705d0a9..1a6a786 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/fft.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/fft/fft.kt @@ -1,6 +1,6 @@ @file:Suppress("LongMethod") -package org.noiseplanet.noisecapture.audio.signal +package org.noiseplanet.noisecapture.audio.signal.fft import kotlin.math.PI import kotlin.math.ceil diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/fftFloat.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/fft/fftFloat.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/fftFloat.kt rename to composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/fft/fftFloat.kt index d4ccda0..6df9b26 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/fftFloat.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/fft/fftFloat.kt @@ -1,6 +1,6 @@ @file:Suppress("LongMethod") -package org.noiseplanet.noisecapture.audio.signal +package org.noiseplanet.noisecapture.audio.signal.fft import kotlin.math.PI import kotlin.math.cos diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/BiquadFilter.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/filter/BiquadFilter.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/BiquadFilter.kt rename to composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/filter/BiquadFilter.kt index ee40411..2ce9084 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/BiquadFilter.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/filter/BiquadFilter.kt @@ -24,7 +24,7 @@ * 14-20 Boulevard Newton Cite Descartes, Champs sur Marne F-77447 Marne la Vallee Cedex 2 FRANCE * or write to scientific.computing@ifsttar.fr */ -package org.noiseplanet.noisecapture.audio.signal +package org.noiseplanet.noisecapture.audio.signal.filter import kotlin.math.log10 diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/DigitalFilter.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/filter/DigitalFilter.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/DigitalFilter.kt rename to composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/filter/DigitalFilter.kt index 4d752d2..370f519 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/DigitalFilter.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/filter/DigitalFilter.kt @@ -1,4 +1,4 @@ -package org.noiseplanet.noisecapture.audio.signal +package org.noiseplanet.noisecapture.audio.signal.filter import kotlin.math.log10 diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/window/SpectrumDataProcessing.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/window/SpectrumDataProcessing.kt new file mode 100644 index 0000000..393fdbc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/audio/signal/window/SpectrumDataProcessing.kt @@ -0,0 +1,186 @@ +package org.noiseplanet.noisecapture.audio.signal.window + +import org.noiseplanet.noisecapture.audio.signal.bluestein.BluesteinFloat +import org.noiseplanet.noisecapture.audio.signal.fft.nextPowerOfTwo +import org.noiseplanet.noisecapture.audio.signal.fft.realFFT +import org.noiseplanet.noisecapture.audio.signal.fft.realFFTFloat +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.log10 +import kotlin.math.min + +/** + * Computation of STFT (Short Time Fourier Transform) + * + * @param sampleRate Sample rate to compute epoch + * @param windowSize Size of the window + * @param windowHop Run a new analysis each windowHop samples + */ +class SpectrumDataProcessing( + val sampleRate: Int, + val windowSize: Int, + private val windowHop: Int, + applyHannWindow: Boolean = true, +) { + + private val circularSamplesBuffer: FloatArray = FloatArray(windowSize) + private var circularBufferCursor: Int = 0 + private val bluestein: BluesteinFloat? = + if (nextPowerOfTwo(windowSize) != windowSize) { + BluesteinFloat(windowSize) + } else { + null + } + + // Windowing correction factors + // [1] F. J. Harris, “On the use of windows for harmonic analysis with the discrete fourier + // transform,”Proceedings of the IEEE, vol. 66, no. 1, pp. 51–83, Jan. 1978. + private val windowCorrectionFactor: Double = + if (applyHannWindow) { + 0.375 + } else { + 1.0 + } + + var samplesUntilWindow: Int = windowSize + val hannWindow: FloatArray? = + if (applyHannWindow) { + FloatArray(windowSize) { + (0.5 * (1 - cos(2 * PI * it / (windowSize - 1)))).toFloat() + } + } else { + null + } + + + init { + require(windowHop > 0) { + "Window hop must be greater than 0" + } + } + + /** + * Process the provided samples and run a STFFT analysis when a window is complete + */ + fun pushSamples( + epoch: Long, + samples: FloatArray, + processedWindows: MutableList? = null, + ): Sequence = sequence { + var processed = 0 + while (processed < samples.size) { + var toFetch = min(samples.size - processed, samplesUntilWindow) + // fill the circular buffer + while (toFetch > 0) { + val copySize = min(circularSamplesBuffer.size - circularBufferCursor, toFetch) + samples.copyInto( + circularSamplesBuffer, + circularBufferCursor, + processed, + processed + copySize + ) + circularBufferCursor += copySize + processed += copySize + toFetch -= copySize + samplesUntilWindow -= copySize + if (circularBufferCursor == circularSamplesBuffer.size) { + circularBufferCursor = 0 + } + } + if (samplesUntilWindow == 0) { + // window complete push it + val windowSamples = FloatArray(windowSize) + circularSamplesBuffer.copyInto( + windowSamples, + windowSize - circularBufferCursor, + 0, + circularBufferCursor + ) + circularSamplesBuffer.copyInto( + windowSamples, + 0, + circularBufferCursor, + circularSamplesBuffer.size + ) + // apply window function + if (hannWindow != null) { + for (i in windowSamples.indices) { + windowSamples[i] *= hannWindow[i] + } + } + val window = Window( + (epoch - ((samples.size - processed) / sampleRate.toDouble()) * 1000.0).toLong(), + windowSamples + ) + yield(processWindow(window)) + processedWindows?.add(window) + samplesUntilWindow = windowHop + } + } + } + + fun reconstructOriginalSignal(processedWindows: List): FloatArray { + val sum = FloatArray(processedWindows.size + processedWindows.size * windowHop) + for (i in processedWindows.indices) { + for (j in 0..Filling the FFT Input Buffer + */ + private fun processWindow(window: Window): SpectrumData { + return SpectrumData(window.epoch, processWindowFloat(window), sampleRate) + } + + private fun processWindowFloat(window: Window): FloatArray { + require(window.samples.size == windowSize) + val fr = (bluestein?.fft(window.samples) ?: realFFTFloat(window.samples)) + val vRef = (((windowSize * windowSize) / 2.0) * windowCorrectionFactor).toFloat() + return FloatArray(fr.size / 2) { i: Int -> + 10 * log10((fr[(i * 2) + 1] * fr[(i * 2) + 1]) / vRef) + } + } + + @Suppress("UnusedPrivateMember") // Unused for now but might come in handy later + private fun processWindowDouble(window: Window): DoubleArray { + val fftWindowSize = nextPowerOfTwo(windowSize) + val fftWindow = DoubleArray(fftWindowSize) + val startIndex = windowSize / 2 + for (i in startIndex.. Unit -typealias SpectrumDataCallback = (spectrumData: SpectrumData) -> Unit +/** + * Record, observe and save audio measurements. + */ +interface MeasurementsService { -const val FFT_SIZE = 4096 -const val FFT_HOP = 2048 + /** + * Starts recording audio through the provided audio source. + * If already a recording is already running, calling this again will have no effect. + */ + fun startRecordingAudio() + + /** + * Stops the currently running audio recording. + * If no recording is running, this will have no effect + */ + fun stopRecordingAudio() -class MeasurementService(private val audioSource: AudioSource) { + /** + * Get a [Flow] of [AcousticIndicatorsData] from the currently running recording. + */ + fun getAcousticIndicatorsFlow(): Flow - var storageObservers = - mutableListOf<(property: KProperty<*>, oldValue: Boolean, newValue: Boolean) -> Unit>() + /** + * Get a [Flow] of sound pressure level values. + */ + fun getWeightedLeqFlow(): Flow - private var storageActivated: Boolean by Delegates.observable(false) { property, oldValue, newValue -> - storageObservers.forEach { - it(property, oldValue, newValue) - } + /** + * Get a [Flow] of sound pressure levels weighted by frequency band. + */ + fun getWeightedSoundPressureLevelFlow(): Flow + + /** + * Get a [Flow] of [SpectrumData] from the currently running recording. + */ + fun getSpectrumDataFlow(): Flow +} + +/** + * Default [MeasurementsService] implementation. + * Can be overridden in platforms to add specific behaviour. + */ +class DefaultMeasurementService( + private val audioSource: AudioSource, + private val logger: Logger, +) : MeasurementsService, KoinComponent { + + companion object { + + const val FFT_SIZE = 4096 + const val FFT_HOP = 2048 + + private const val SPL_DECAY_RATE = FAST_DECAY_RATE + private const val SPL_WINDOW_TIME = WINDOW_TIME } - private var acousticIndicatorsProcessing: AcousticIndicatorsProcessing? = null - private var fftTool: WindowAnalysis? = null - private var onAcousticIndicatorsData: AcousticIndicatorsCallback? = null - private var onSpectrumData: SpectrumDataCallback? = null + + private var indicatorsProcessing: AcousticIndicatorsProcessing? = null + private var spectrumDataProcessing: SpectrumDataProcessing? = null + private var audioJob: Job? = null + private val acousticIndicatorsFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val spectrumDataFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - @OptIn(DelicateCoroutinesApi::class) - private fun startAudioRecord() { + override fun startRecordingAudio() { if (audioJob?.isActive == true) { + logger.debug("Audio recording is already running. Don't start again.") return } - audioJob = GlobalScope.launch { - audioSource.setup().collect { audioSamples -> - if (onSpectrumData != null) { - if (fftTool == null) { - fftTool = WindowAnalysis(audioSamples.sampleRate, FFT_SIZE, FFT_HOP) + logger.debug("Starting recording audio samples...") + // Start recording and processing audio samples in a background thread + audioJob = coroutineScope.launch { + audioSource.setup() + .flowOn(Dispatchers.Default) + .collect { audioSamples -> + // Process acoustic indicators + if (indicatorsProcessing?.sampleRate != audioSamples.sampleRate) { + logger.debug("Processing audio indicators with sample rate of ${audioSamples.sampleRate}") + indicatorsProcessing = AcousticIndicatorsProcessing(audioSamples.sampleRate) } - fftTool?.pushSamples(audioSamples.epoch, audioSamples.samples) - ?.forEach { spectrumData -> - onSpectrumData?.let { callback -> callback(spectrumData) } + indicatorsProcessing?.processSamples(audioSamples) + ?.forEach { + acousticIndicatorsFlow.tryEmit(it) } - } - if (onAcousticIndicatorsData != null || storageActivated) { - if (acousticIndicatorsProcessing == null) { - acousticIndicatorsProcessing = AcousticIndicatorsProcessing( - audioSamples.sampleRate + + // Process spectrum data + if (spectrumDataProcessing?.sampleRate != audioSamples.sampleRate) { + logger.debug("Processing spectrum data with sample rate of ${audioSamples.sampleRate}") + spectrumDataProcessing = SpectrumDataProcessing( + sampleRate = audioSamples.sampleRate, + windowSize = FFT_SIZE, + windowHop = FFT_HOP ) } - acousticIndicatorsProcessing!!.processSamples(audioSamples) - .forEach { acousticIndicators -> - if (onAcousticIndicatorsData != null) { - onAcousticIndicatorsData?.let { callback -> - callback( - acousticIndicators - ) - } - } + spectrumDataProcessing?.pushSamples(audioSamples.epoch, audioSamples.samples) + ?.forEach { + spectrumDataFlow.tryEmit(it) } } - } } } - private fun stopAudioRecord() { + override fun stopRecordingAudio() { audioJob?.cancel() audioSource.release() } - /** - * Start collecting measurements to be forwarded to observers - */ - fun collectAudioIndicators(): Flow = callbackFlow { - setAudioIndicatorsObserver { trySend(it) } - awaitClose { - resetAudioIndicatorsObserver() - } - } - - fun collectSpectrumData(): Flow = callbackFlow { - setSpectrumDataObserver { trySend(it) } - awaitClose { - resetSpectrumDataObserver() - } + override fun getAcousticIndicatorsFlow(): Flow { + return acousticIndicatorsFlow.asSharedFlow() } - private fun setSpectrumDataObserver(onSpectrumData: SpectrumDataCallback) { - this.onSpectrumData = onSpectrumData - startAudioRecord() + override fun getSpectrumDataFlow(): Flow { + return spectrumDataFlow.asSharedFlow() } - private fun canReleaseAudio(): Boolean { - return !storageActivated && - onAcousticIndicatorsData == null && - onAcousticIndicatorsData == null - } + override fun getWeightedLeqFlow(): Flow { + val levelDisplay = LevelDisplayWeightedDecay(SPL_DECAY_RATE, SPL_WINDOW_TIME) - private fun resetSpectrumDataObserver() { - onSpectrumData = null - if (canReleaseAudio()) { - stopAudioRecord() - } + return getAcousticIndicatorsFlow() + .map { + levelDisplay.getWeightedValue(it.leq) + } } - private fun setAudioIndicatorsObserver(onAcousticIndicatorsData: AcousticIndicatorsCallback) { - startAudioRecord() - this.onAcousticIndicatorsData = onAcousticIndicatorsData - } + override fun getWeightedSoundPressureLevelFlow(): Flow { + var levelDisplayBands: Array? = null - private fun resetAudioIndicatorsObserver() { - onAcousticIndicatorsData = null - if (canReleaseAudio()) { - stopAudioRecord() - } + return getAcousticIndicatorsFlow() + .map { indicators -> + if (levelDisplayBands == null) { + levelDisplayBands = Array(indicators.nominalFrequencies.size) { + LevelDisplayWeightedDecay(SPL_DECAY_RATE, SPL_WINDOW_TIME) + } + } + DoubleArray(indicators.nominalFrequencies.size) { index -> + levelDisplayBands?.get(index) + ?.getWeightedValue(indicators.thirdOctave[index]) + ?: 0.0 + } + } } } diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/permission/PermissionModule.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/permission/PermissionModule.kt index 69dc324..57fb593 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/permission/PermissionModule.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/permission/PermissionModule.kt @@ -14,6 +14,8 @@ internal expect fun platformPermissionModule(): Module internal val defaultPermissionModule = module { + single { DefaultPermissionService() } + for (permission in Permission.entries) { // Register a default delegate implementation for each permission that will be overridden // in each platform module depending on the supported permissions diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/AppBar.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/AppBar.kt index 27dfac7..17778d1 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/AppBar.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/AppBar.kt @@ -13,10 +13,11 @@ import androidx.compose.ui.Modifier import noisecapture.composeapp.generated.resources.Res import noisecapture.composeapp.generated.resources.back_button import org.jetbrains.compose.resources.stringResource +import org.noiseplanet.noisecapture.ui.navigation.Route @Composable fun AppBar( - currentScreen: NavigationRoute, + currentScreen: Route, canNavigateBack: Boolean, navigateUp: () -> Unit, modifier: Modifier = Modifier, diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/Greeting.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/Greeting.kt deleted file mode 100644 index aaefce3..0000000 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/Greeting.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.noiseplanet.noisecapture.ui.components - -import getPlatform - -class Greeting { - - private val platform = getPlatform() - - fun greet(): String { - return "Hello, ${platform.name}!" - } -} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/MenuItem.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/MenuItem.kt deleted file mode 100644 index b0ce577..0000000 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/MenuItem.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.noiseplanet.noisecapture.ui.components - -import androidx.compose.ui.graphics.vector.ImageVector - -data class MenuItem( - val label: String, - val imageVector: ImageVector, - val onClick: () -> Unit, -) diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/measurement/LegendElement.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/measurement/LegendElement.kt deleted file mode 100644 index b0238e5..0000000 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/measurement/LegendElement.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.noiseplanet.noisecapture.ui.components.measurement - -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.TextMeasurer - -data class LegendElement( - val text : TextLayoutResult, - val xPos : Float, - val textPos : Float, - val depth : Int -) diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/measurement/SpectrogramBitmap.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/measurement/SpectrogramBitmap.kt deleted file mode 100644 index 8457ac4..0000000 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/components/measurement/SpectrogramBitmap.kt +++ /dev/null @@ -1,226 +0,0 @@ -package org.noiseplanet.noisecapture.ui.components.measurement - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.unit.IntSize -import org.noiseplanet.noisecapture.audio.signal.SpectrumData -import org.noiseplanet.noisecapture.measurements.FFT_SIZE -import kotlin.math.floor -import kotlin.math.log10 -import kotlin.math.max -import kotlin.math.min -import kotlin.math.pow - -/** - * Convert FFT result into spectrogram bitmap bytearray - * TODO: Cleanup and document - */ -class SpectrogramBitmap { - - companion object { - - val bmpHeader = intArrayOf( // All values are little-endian - 0x42, 0x4D, // Signature 'BM' - 0xaa, 0x00, 0x00, 0x00, // Size: 170 bytes - 0x00, 0x00, // Unused - 0x00, 0x00, // Unused - 0x8a, 0x00, 0x00, 0x00, // Offset to image data - 0x7c, 0x00, 0x00, 0x00, // DIB header size (124 bytes) - 0x04, 0x00, 0x00, 0x00, // Width (4px) - 0x02, 0x00, 0x00, 0x00, // Height (2px) - 0x01, 0x00, // Planes (1) - 0x20, 0x00, // Bits per pixel (32) - 0x03, 0x00, 0x00, 0x00, // Format (bitfield = use bitfields | no compression) - 0x20, 0x00, 0x00, 0x00, // Image raw size (32 bytes) - 0x13, 0x0B, 0x00, 0x00, // Horizontal print resolution (2835 = 72dpi * 39.3701) - 0x13, 0x0B, 0x00, 0x00, // Vertical print resolution (2835 = 72dpi * 39.3701) - 0x00, 0x00, 0x00, 0x00, // Colors in palette (none) - 0x00, 0x00, 0x00, 0x00, // Important colors (0 = all) - 0x00, 0x00, 0xFF, 0x00, // R bitmask (00FF0000) - 0x00, 0xFF, 0x00, 0x00, // G bitmask (0000FF00) - 0xFF, 0x00, 0x00, 0x00, // B bitmask (000000FF) - 0x00, 0x00, 0x00, 0xFF, // A bitmask (FF000000) - 0x42, 0x47, 0x52, 0x73, // sRGB color space - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, // Unused R, G, B entries for color space - 0x00, 0x00, 0x00, 0x00, // Unused Gamma X entry for color space - 0x00, 0x00, 0x00, 0x00, // Unused Gamma Y entry for color space - 0x00, 0x00, 0x00, 0x00, // Unused Gamma Z entry for color space - 0x00, 0x00, 0x00, 0x00, // Unknown - 0x00, 0x00, 0x00, 0x00, // Unknown - 0x00, 0x00, 0x00, 0x00, // Unknown - 0x00, 0x00, 0x00, 0x00 // Unknown - // Image data after this - ).map { it.toByte() }.toByteArray() - - const val sizeIndex = 2 - const val widthIndex = 18 - const val heightIndex = 22 - const val rawSizeIndex = 34 - - fun parseColor(colorString: String): Int { - var color = colorString.substring(1).toLong(16) - if (colorString.length == 7) { - // Set the alpha value - color = color or 0x00000000ff000000L - } else { - require(colorString.length != 9) { "Unknown color" } - } - return color.toInt() - } - - fun String.toComposeColor(): Color { - return Color(parseColor(this)) - } - - enum class ScaleMode { - SCALE_LINEAR, - SCALE_LOG - } - - val frequencyLegendPositionLog = - intArrayOf(63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000, 24000) - - val frequencyLegendPositionLinear = IntArray(24) { it * 1000 + 1000 } - - val colorRamp = arrayOf( - "#303030".toComposeColor(), - "#2D3C2D".toComposeColor(), - "#2A482A".toComposeColor(), - "#275427".toComposeColor(), - "#246024".toComposeColor(), - "#216C21".toComposeColor(), - "#3F8E19".toComposeColor(), - "#61A514".toComposeColor(), - "#82BB0F".toComposeColor(), - "#A4D20A".toComposeColor(), - "#C5E805".toComposeColor(), - "#E7FF00".toComposeColor(), - "#EBD400".toComposeColor(), - "#EFAA00".toComposeColor(), - "#F37F00".toComposeColor(), - "#F75500".toComposeColor(), - "#FB2A00".toComposeColor(), - ) - - fun createSpectrogram( - size: IntSize, - scaleMode: ScaleMode, - sampleRate: Double, - ): SpectrogramDataModel { - val byteArray = ByteArray(bmpHeader.size + Int.SIZE_BYTES * size.width * size.height) - bmpHeader.copyInto(byteArray) - // fill with changing header data - val rawPixelSize = size.width * size.height * Int.SIZE_BYTES - rawPixelSize.toLittleEndianBytes().copyInto(byteArray, rawSizeIndex) - (rawPixelSize + bmpHeader.size).toLittleEndianBytes().copyInto(byteArray, sizeIndex) - size.width.toLittleEndianBytes().copyInto(byteArray, widthIndex) - size.height.toLittleEndianBytes().copyInto(byteArray, heightIndex) - return SpectrogramDataModel( - size, - byteArray, - scaleMode = scaleMode, - sampleRate = sampleRate - ) - } - - /** - * Convert Int into little endian array of bytes - */ - fun Int.toLittleEndianBytes(): ByteArray = byteArrayOf( - this.toByte(), this.ushr(8).toByte(), - this.ushr(16).toByte(), this.ushr(24).toByte() - ) - - } - - /** - * @constructor - * @si - */ - data class SpectrogramDataModel( - val size: IntSize, - val byteArray: ByteArray, - var offset: Int = 0, - val scaleMode: ScaleMode, - val sampleRate: Double, - ) { - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as SpectrogramDataModel - - if (size != other.size) return false - return byteArray.contentEquals(other.byteArray) - } - - override fun hashCode(): Int { - var result = size.hashCode() - result = 31 * result + byteArray.contentHashCode() - return result - } - - fun pushSpectrumToSpectrogramData( - fftResult: SpectrumData, - mindB: Double, rangedB: Double, gain: Double, - ) { - // generate columns of pixels - // merge power of each frequencies following the destination bitmap resolution - val hertzBySpectrumCell = sampleRate / FFT_SIZE.toDouble() - val frequencyLegendPosition = when (scaleMode) { - ScaleMode.SCALE_LOG -> frequencyLegendPositionLog - else -> frequencyLegendPositionLinear - } - var lastProcessFrequencyIndex = 0 - val freqByPixel = fftResult.spectrum.size / size.height.toDouble() - for (pixel in 0.. + MenuItemViewModel(label, imageVector, route) + } + viewModel { + HomeScreenViewModel() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/HomeScreen.kt new file mode 100644 index 0000000..a2c0a92 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/HomeScreen.kt @@ -0,0 +1,52 @@ +package org.noiseplanet.noisecapture.ui.features.home + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import org.koin.compose.koinInject +import org.noiseplanet.noisecapture.ui.features.home.menuitem.HomeScreenViewModel +import org.noiseplanet.noisecapture.ui.features.home.menuitem.MenuItem + +/** + * Home screen layout. + * + * TODO: Improve UI once more clearly defined + */ +@Composable +fun HomeScreen( + navigationController: NavController, + viewModel: HomeScreenViewModel = koinInject(), +) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 96.dp), + contentPadding = PaddingValues( + start = 24.dp, + top = 24.dp, + end = 24.dp, + bottom = 24.dp + ), + content = { + items(viewModel.menuItems) { viewModel -> + MenuItem( + viewModel, + navigateTo = { route -> + navigationController.navigate(route.name) + }, + ) + } + } + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/HomeScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/HomeScreenViewModel.kt new file mode 100644 index 0000000..6ea2fd8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/HomeScreenViewModel.kt @@ -0,0 +1,60 @@ +package org.noiseplanet.noisecapture.ui.features.home.menuitem + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.CenterFocusWeak +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.HistoryEdu +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Timeline +import androidx.lifecycle.ViewModel +import noisecapture.composeapp.generated.resources.Res +import noisecapture.composeapp.generated.resources.menu_about +import noisecapture.composeapp.generated.resources.menu_calibration +import noisecapture.composeapp.generated.resources.menu_feedback +import noisecapture.composeapp.generated.resources.menu_help +import noisecapture.composeapp.generated.resources.menu_history +import noisecapture.composeapp.generated.resources.menu_map +import noisecapture.composeapp.generated.resources.menu_new_measurement +import noisecapture.composeapp.generated.resources.menu_settings +import noisecapture.composeapp.generated.resources.menu_statistics +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.parameter.parametersOf +import org.noiseplanet.noisecapture.ui.navigation.Route + +class HomeScreenViewModel : ViewModel(), KoinComponent { + + val menuItems: Array = arrayOf( + get { + parametersOf(Res.string.menu_new_measurement, Icons.Filled.Mic, Route.RequestPermission) + }, + get { + parametersOf(Res.string.menu_history, Icons.Filled.History, null) + }, + get { + parametersOf(Res.string.menu_feedback, Icons.Filled.HistoryEdu, null) + }, + get { + parametersOf(Res.string.menu_statistics, Icons.Filled.Timeline, null) + }, + get { + parametersOf(Res.string.menu_map, Icons.Filled.Map, null) + }, + get { + parametersOf(Res.string.menu_help, Icons.AutoMirrored.Filled.Help, null) + }, + get { + parametersOf(Res.string.menu_about, Icons.Filled.Info, null) + }, + get { + parametersOf(Res.string.menu_calibration, Icons.Filled.CenterFocusWeak, null) + }, + get { + parametersOf(Res.string.menu_settings, Icons.Filled.Settings, null) + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/MenuItem.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/MenuItem.kt new file mode 100644 index 0000000..eda8965 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/MenuItem.kt @@ -0,0 +1,34 @@ +package org.noiseplanet.noisecapture.ui.features.home.menuitem + +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material3.Button +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.noiseplanet.noisecapture.ui.navigation.Route + +@Composable +fun MenuItem( + viewModel: MenuItemViewModel, + navigateTo: (Route) -> Unit, + modifier: Modifier = Modifier, +) { + Button( + onClick = { + viewModel.route?.let { + navigateTo(it) + } + }, + modifier = Modifier.aspectRatio(1f).padding(12.dp), + ) { + Icon( + imageVector = viewModel.imageVector, + stringResource(viewModel.label), + modifier.fillMaxSize(), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/MenuItemViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/MenuItemViewModel.kt new file mode 100644 index 0000000..656fa4a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/home/menuitem/MenuItemViewModel.kt @@ -0,0 +1,12 @@ +package org.noiseplanet.noisecapture.ui.features.home.menuitem + +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.ViewModel +import org.jetbrains.compose.resources.StringResource +import org.noiseplanet.noisecapture.ui.navigation.Route + +class MenuItemViewModel( + val label: StringResource, + val imageVector: ImageVector, + val route: Route? = null, +) : ViewModel() diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementModule.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementModule.kt new file mode 100644 index 0000000..0f88b46 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementModule.kt @@ -0,0 +1,30 @@ +package org.noiseplanet.noisecapture.ui.features.measurement + +import org.koin.compose.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.noiseplanet.noisecapture.ui.features.measurement.indicators.AcousticIndicatorsViewModel +import org.noiseplanet.noisecapture.ui.features.measurement.plot.spectrogram.SpectrogramPlotViewModel +import org.noiseplanet.noisecapture.ui.features.measurement.plot.spectrum.SpectrumPlotViewModel + + +val measurementModule = module { + + viewModel { + AcousticIndicatorsViewModel(measurementService = get()) + } + + viewModel { + SpectrumPlotViewModel(measurementsService = get()) + } + + viewModel { + SpectrogramPlotViewModel( + measurementsService = get(), + logger = get() + ) + } + + viewModel { + MeasurementScreenViewModel(measurementsService = get()) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementPager.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementPager.kt new file mode 100644 index 0000000..ff388c3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementPager.kt @@ -0,0 +1,76 @@ +package org.noiseplanet.noisecapture.ui.features.measurement + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import org.noiseplanet.noisecapture.ui.features.measurement.plot.spectrogram.SpectrogramPlotView +import org.noiseplanet.noisecapture.ui.features.measurement.plot.spectrum.SpectrumPlotView + +@OptIn(ExperimentalFoundationApi::class, KoinExperimentalAPI::class) +@Composable +fun MeasurementPager() { + val animationScope = rememberCoroutineScope() + val pagerState = rememberPagerState(pageCount = { MeasurementTabState.entries.size }) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + TabRow(selectedTabIndex = pagerState.currentPage) { + MeasurementTabState.entries.forEach { entry -> + Tab( + text = { Text(MEASUREMENT_TAB_LABEL[entry.ordinal]) }, + selected = pagerState.currentPage == entry.ordinal, + onClick = { animationScope.launch { pagerState.animateScrollToPage(entry.ordinal) } } + ) + } + } + HorizontalPager(state = pagerState) { page -> + when (MeasurementTabState.entries[page]) { + MeasurementTabState.SPECTROGRAM -> Box(Modifier.fillMaxSize()) { + SpectrogramPlotView( + viewModel = koinViewModel(), + modifier = Modifier.fillMaxSize() + ) + } + + MeasurementTabState.SPECTRUM -> Box(Modifier.fillMaxSize()) { + SpectrumPlotView( + viewModel = koinViewModel(), + modifier = Modifier.fillMaxSize() + ) + } + + else -> Surface( + Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Text( + text = "Text tab ${MEASUREMENT_TAB_LABEL[page]} selected", + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } +} + +enum class MeasurementTabState { + SPECTRUM, + SPECTROGRAM, + MAP +} + +val MEASUREMENT_TAB_LABEL = listOf("Spectrum", "Spectrogram", "Map") diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementScreen.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementScreen.kt new file mode 100644 index 0000000..bf92e2f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementScreen.kt @@ -0,0 +1,75 @@ +package org.noiseplanet.noisecapture.ui.features.measurement + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import org.noiseplanet.noisecapture.ui.features.measurement.indicators.AcousticIndicatorsView + +const val DEFAULT_SAMPLE_RATE = 48000.0 + +val NOISE_LEVEL_FONT_SIZE = TextUnit(50F, TextUnitType.Sp) +val SPECTRUM_PLOT_SQUARE_WIDTH = 10.dp +val SPECTRUM_PLOT_SQUARE_OFFSET = 1.dp + +@OptIn(KoinExperimentalAPI::class) +@Composable +fun MeasurementScreen( + viewModel: MeasurementScreenViewModel = koinInject(), + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, +) { + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> viewModel.startRecordingAudio() + Lifecycle.Event.ON_STOP -> viewModel.stopRecordingAudio() + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + BoxWithConstraints { + if (maxWidth > maxHeight) { + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxWidth(.5F)) { + AcousticIndicatorsView(viewModel = koinViewModel()) + } + Column(modifier = Modifier) { + MeasurementPager() + } + } + } else { + Column(modifier = Modifier.fillMaxSize()) { + AcousticIndicatorsView(viewModel = koinViewModel()) + MeasurementPager() + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementScreenViewModel.kt new file mode 100644 index 0000000..5819683 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/MeasurementScreenViewModel.kt @@ -0,0 +1,13 @@ +package org.noiseplanet.noisecapture.ui.features.measurement + +import androidx.lifecycle.ViewModel +import org.noiseplanet.noisecapture.measurements.MeasurementsService + +class MeasurementScreenViewModel( + private val measurementsService: MeasurementsService, +) : ViewModel() { + + fun startRecordingAudio() = measurementsService.startRecordingAudio() + + fun stopRecordingAudio() = measurementsService.stopRecordingAudio() +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/indicators/AcousticIndicatorsView.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/indicators/AcousticIndicatorsView.kt new file mode 100644 index 0000000..6372afa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/indicators/AcousticIndicatorsView.kt @@ -0,0 +1,138 @@ +package org.noiseplanet.noisecapture.ui.features.measurement.indicators + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import org.noiseplanet.noisecapture.ui.features.measurement.NOISE_LEVEL_FONT_SIZE +import org.noiseplanet.noisecapture.ui.features.measurement.indicators.AcousticIndicatorsViewModel.Companion.VU_METER_DB_MAX +import org.noiseplanet.noisecapture.ui.features.measurement.indicators.AcousticIndicatorsViewModel.Companion.VU_METER_DB_MIN +import org.noiseplanet.noisecapture.ui.features.measurement.plot.spectrum.SpectrumPlotViewModel.Companion.noiseColorRampSpl +import kotlin.math.round + + +@Composable +fun AcousticIndicatorsView( + viewModel: AcousticIndicatorsViewModel, +) { + val rightRoundedSquareShape: Shape = RoundedCornerShape( + topStart = 0.dp, + topEnd = 40.dp, + bottomStart = 0.dp, + bottomEnd = 40.dp + ) + val noiseLevel by viewModel.soundPressureLevelFlow.collectAsState(0.0) + + Column() { + Row( + horizontalArrangement = Arrangement.SpaceBetween + ) { + Surface( + Modifier.padding(top = 20.dp, bottom = 10.dp).weight(1F), + color = MaterialTheme.colorScheme.background, + shape = rightRoundedSquareShape, + shadowElevation = 10.dp + ) { + Row( + modifier = Modifier.padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + buildAnnotatedString { + withStyle( + SpanStyle( + fontSize = TextUnit( + 18F, + TextUnitType.Sp + ), + ) + ) + { append("dB(A)") } + }, + modifier = Modifier.align(Alignment.CenterVertically) + ) + Text( + buildNoiseLevelText(noiseLevel), + modifier = Modifier.align(Alignment.CenterVertically) + ) + } + } + Row( + Modifier.align(Alignment.CenterVertically), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + listOf( + // TODO: Localize this + MeasurementStatistics("Min", "-"), + MeasurementStatistics("Avg", "-"), + MeasurementStatistics("Max", "-") + ).forEach { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(10.dp) + ) { + Text(it.label) + Text(it.value) + } + } + } + } + + VuMeter( + ticks = viewModel.vuMeterTicks, + minimum = VU_METER_DB_MIN, + maximum = VU_METER_DB_MAX, + value = noiseLevel, + Modifier.fillMaxWidth() + .height(50.dp) + .padding(start = 30.dp, end = 30.dp), + ) + } +} + +@Composable +private fun buildNoiseLevelText(noiseLevel: Double): AnnotatedString = buildAnnotatedString { + val inRangeNoise = noiseLevel > VU_METER_DB_MIN && noiseLevel < VU_METER_DB_MAX + val colorIndex = noiseColorRampSpl.indexOfFirst { pair -> pair.first < noiseLevel } + withStyle( + style = SpanStyle( + color = if (inRangeNoise) { + noiseColorRampSpl[colorIndex].second + } else { + MaterialTheme.colorScheme.onPrimary + }, + fontSize = NOISE_LEVEL_FONT_SIZE, + baselineShift = BaselineShift.None + ) + ) { + when { + inRangeNoise -> append("${round(noiseLevel * 10) / 10}") + else -> append("-") + } + } +} + +private data class MeasurementStatistics( + val label: String, + val value: String, +) diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/indicators/AcousticIndicatorsViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/indicators/AcousticIndicatorsViewModel.kt new file mode 100644 index 0000000..97bf3a3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/indicators/AcousticIndicatorsViewModel.kt @@ -0,0 +1,28 @@ +package org.noiseplanet.noisecapture.ui.features.measurement.indicators + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.Flow +import org.noiseplanet.noisecapture.measurements.MeasurementsService + +class AcousticIndicatorsViewModel( + private val measurementService: MeasurementsService, +) : ViewModel() { + + companion object { + + /** + * Number of ticks to display along the X-Axis + * Tick values will be determined from provided min and max values + */ + const val VU_METER_TICKS_COUNT: Int = 6 + + const val VU_METER_DB_MIN = 20.0 + const val VU_METER_DB_MAX = 120.0 + } + + val vuMeterTicks: IntArray = IntArray(size = VU_METER_TICKS_COUNT) { index -> + (VU_METER_DB_MIN + ((VU_METER_DB_MAX - VU_METER_DB_MIN) / (VU_METER_TICKS_COUNT - 1) * index)).toInt() + } + + val soundPressureLevelFlow: Flow = measurementService.getWeightedLeqFlow() +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/indicators/VuMeter.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/indicators/VuMeter.kt new file mode 100644 index 0000000..2f27da5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/indicators/VuMeter.kt @@ -0,0 +1,56 @@ +package org.noiseplanet.noisecapture.ui.features.measurement.indicators + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import org.noiseplanet.noisecapture.ui.features.measurement.plot.spectrum.SpectrumPlotViewModel.Companion.noiseColorRampSpl + +private val BAR_HEIGHT: Dp = 32.dp + +@Composable +fun VuMeter( + ticks: IntArray, + minimum: Double, + maximum: Double, + value: Double, + modifier: Modifier = Modifier, +) { + val valueRatio = (value - minimum) / (maximum - minimum) + val colorIndex = noiseColorRampSpl.indexOfFirst { pair -> pair.first < value } + val color = noiseColorRampSpl[colorIndex].second + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + + Row(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth(valueRatio.toFloat()) + .height(BAR_HEIGHT) + .clip(RoundedCornerShape(percent = 50)) + .background(color) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + ticks.forEach { + Text(text = "$it", fontSize = TextUnit(10F, TextUnitType.Sp)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/LegendElement.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/LegendElement.kt new file mode 100644 index 0000000..5d86448 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/LegendElement.kt @@ -0,0 +1,10 @@ +package org.noiseplanet.noisecapture.ui.features.measurement.plot + +import androidx.compose.ui.text.TextLayoutResult + +data class LegendElement( + val text: TextLayoutResult, + val xPos: Float, + val textPos: Float, + val depth: Int, +) diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/PlotAxisBuilder.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/PlotAxisBuilder.kt new file mode 100644 index 0000000..c19ed7c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/PlotAxisBuilder.kt @@ -0,0 +1,173 @@ +package org.noiseplanet.noisecapture.ui.features.measurement.plot + +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.round + +class PlotAxisBuilder { + + val tickStroke = 2.dp + val tickLength = 4.dp + + fun timeAxisFormater(timeValue: Double): String { + return "+${round(timeValue).toInt()}s" + } + + fun noiseLevelAxisFormater(timeValue: Double): String { + return "${round(timeValue).toInt()} dB" + } + + // TODO: Cleanup legend generation functions + @Suppress("LongParameterList") + fun makeXLegend( + textMeasurer: TextMeasurer, + xValue: Double, + legendWidth: Float, + xPerPixel: Double, + depth: Int, + formater: (x: Double) -> String, + ascending: Boolean, + ): LegendElement { + val xPos = + when { + ascending -> (xValue / xPerPixel).toFloat() + else -> (legendWidth - xValue / xPerPixel).toFloat() + } + val legendText = buildAnnotatedString { + withStyle(style = SpanStyle()) { + append(formater(xValue)) + } + } + val textLayout = textMeasurer.measure(legendText) + val textPos = min( + legendWidth - textLayout.size.width, + max(0F, xPos - textLayout.size.width / 2) + ) + return LegendElement(textLayout, xPos, textPos, depth) + } + + // TODO: Cleanup legend generation functions + @Suppress("LongParameterList") + fun recursiveLegendBuild( + textMeasurer: TextMeasurer, + timeValue: Double, + legendWidth: Float, + timePerPixel: Double, + minPixel: Float, + maxPixel: Float, + xLeftValue: Double, + xRightValue: Double, + feedElements: ArrayList, + depth: Int, + formater: (x: Double) -> String, + ) { + val legendElement = + makeXLegend( + textMeasurer, + timeValue, + legendWidth, + timePerPixel, + depth, + formater, + xLeftValue < xRightValue + ) + // Add sub axis element if the text does not overlap with neighboring texts + if (legendElement.textPos > minPixel && legendElement.xPos + legendElement.text.size.width / 2 < maxPixel) { + feedElements.add(legendElement) + // left legend, + x seconds + recursiveLegendBuild( + textMeasurer, + xLeftValue + (timeValue - xLeftValue) / 2, + legendWidth, + timePerPixel, + minPixel, + legendElement.textPos, + xLeftValue, + timeValue, + feedElements, + depth + 1, + formater + ) + // right legend, - x seconds + recursiveLegendBuild( + textMeasurer, + timeValue + (xRightValue - timeValue) / 2, + legendWidth, + timePerPixel, + legendElement.textPos + legendElement.text.size.width, + maxPixel, + timeValue, + xRightValue, + feedElements, + depth + 1, + formater + ) + } + } + + fun makeXLabels( + textMeasurer: TextMeasurer, + leftValue: Double, + rightValue: Double, + xLegendWidth: Float, + formater: (x: Double) -> String, + ): ArrayList { + val xPerPixel = abs(leftValue - rightValue) / xLegendWidth + val legendElements = ArrayList() + val leftLegend = + makeXLegend( + textMeasurer, + leftValue, + xLegendWidth, + xPerPixel, + -1, + formater, + leftValue < rightValue + ) + val rightLegend = + makeXLegend( + textMeasurer, + rightValue, + xLegendWidth, + xPerPixel, + -1, + formater, + leftValue < rightValue + ) + legendElements.add(leftLegend) + legendElements.add(rightLegend) + // Add axis texts between left and rightmost axis texts (until it overlaps) + recursiveLegendBuild( + textMeasurer, + abs(leftValue - rightValue) / 2, + xLegendWidth, + xPerPixel, + leftLegend.text.size.width.toFloat(), + rightLegend.xPos - rightLegend.text.size.width, + leftValue, + rightValue, + legendElements, + 0, + formater + ) + // find depth index with maximum number of elements (to generate same intervals on legend) + val legendDepthCount = IntArray(legendElements.maxOf { it.depth } + 1) { 0 } + legendElements.forEach { + if (it.depth >= 0) { + legendDepthCount[it.depth] += 1 + } + } + // remove sub-axis texts with isolated depth (should produce same intervals between axis text) + legendElements.removeAll { + it.depth > 0 && legendDepthCount[it.depth] != (2.0.pow(it.depth)).toInt() + } + return legendElements + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/PlotBitmapOverlay.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/PlotBitmapOverlay.kt new file mode 100644 index 0000000..620fe1c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/PlotBitmapOverlay.kt @@ -0,0 +1,13 @@ +package org.noiseplanet.noisecapture.ui.features.measurement.plot + +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ImageBitmap + + +data class PlotBitmapOverlay( + val imageBitmap: ImageBitmap, + val imageSize: Size, + val horizontalLegendSize: Size, + val verticalLegendSize: Size, + val plotSettingsHashCode: Int, +) diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrogram/SpectrogramBitmap.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrogram/SpectrogramBitmap.kt new file mode 100644 index 0000000..ecb0d10 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrogram/SpectrogramBitmap.kt @@ -0,0 +1,231 @@ +package org.noiseplanet.noisecapture.ui.features.measurement.plot.spectrogram + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.IntSize +import org.noiseplanet.noisecapture.audio.signal.window.SpectrumData +import org.noiseplanet.noisecapture.measurements.DefaultMeasurementService.Companion.FFT_SIZE +import org.noiseplanet.noisecapture.util.toComposeColor +import org.noiseplanet.noisecapture.util.toImageBitmap +import org.noiseplanet.noisecapture.util.toLittleEndianBytes +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + +/** + * Convert FFT result into spectrogram bitmap bytearray + * TODO: Document + */ +data class SpectrogramBitmap( + val size: IntSize, + val scaleMode: ScaleMode, + var offset: Int = 0, + private val byteArray: ByteArray = ByteArray( + bmpHeader.size + Int.SIZE_BYTES * size.width * size.height + ), +) { + + companion object { + + val bmpHeader = intArrayOf( // All values are little-endian + 0x42, 0x4D, // Signature 'BM' + 0xaa, 0x00, 0x00, 0x00, // Size: 170 bytes + 0x00, 0x00, // Unused + 0x00, 0x00, // Unused + 0x8a, 0x00, 0x00, 0x00, // Offset to image data + 0x7c, 0x00, 0x00, 0x00, // DIB header size (124 bytes) + 0x04, 0x00, 0x00, 0x00, // Width (4px) + 0x02, 0x00, 0x00, 0x00, // Height (2px) + 0x01, 0x00, // Planes (1) + 0x20, 0x00, // Bits per pixel (32) + 0x03, 0x00, 0x00, 0x00, // Format (bitfield = use bitfields | no compression) + 0x20, 0x00, 0x00, 0x00, // Image raw size (32 bytes) + 0x13, 0x0B, 0x00, 0x00, // Horizontal print resolution (2835 = 72dpi * 39.3701) + 0x13, 0x0B, 0x00, 0x00, // Vertical print resolution (2835 = 72dpi * 39.3701) + 0x00, 0x00, 0x00, 0x00, // Colors in palette (none) + 0x00, 0x00, 0x00, 0x00, // Important colors (0 = all) + 0x00, 0x00, 0xFF, 0x00, // R bitmask (00FF0000) + 0x00, 0xFF, 0x00, 0x00, // G bitmask (0000FF00) + 0xFF, 0x00, 0x00, 0x00, // B bitmask (000000FF) + 0x00, 0x00, 0x00, 0xFF, // A bitmask (FF000000) + 0x42, 0x47, 0x52, 0x73, // sRGB color space + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, // Unused R, G, B entries for color space + 0x00, 0x00, 0x00, 0x00, // Unused Gamma X entry for color space + 0x00, 0x00, 0x00, 0x00, // Unused Gamma Y entry for color space + 0x00, 0x00, 0x00, 0x00, // Unused Gamma Z entry for color space + 0x00, 0x00, 0x00, 0x00, // Unknown + 0x00, 0x00, 0x00, 0x00, // Unknown + 0x00, 0x00, 0x00, 0x00, // Unknown + 0x00, 0x00, 0x00, 0x00 // Unknown + // Image data after this + ).map { it.toByte() }.toByteArray() + + private const val SIZE_INDEX = 2 + private const val WIDTH_INDEX = 18 + private const val HEIGHT_INDEX = 22 + private const val RAW_SIZE_INDEX = 34 + + val frequencyLegendPositionLog = + intArrayOf(63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000, 24000) + + val frequencyLegendPositionLinear = IntArray(24) { it * 1000 + 1000 } + + val colorRamp = arrayOf( + // TODO: Move this to color resources instead? + "#303030".toComposeColor(), + "#2D3C2D".toComposeColor(), + "#2A482A".toComposeColor(), + "#275427".toComposeColor(), + "#246024".toComposeColor(), + "#216C21".toComposeColor(), + "#3F8E19".toComposeColor(), + "#61A514".toComposeColor(), + "#82BB0F".toComposeColor(), + "#A4D20A".toComposeColor(), + "#C5E805".toComposeColor(), + "#E7FF00".toComposeColor(), + "#EBD400".toComposeColor(), + "#EFAA00".toComposeColor(), + "#F37F00".toComposeColor(), + "#F75500".toComposeColor(), + "#FB2A00".toComposeColor(), + ) + } + + enum class ScaleMode { + SCALE_LINEAR, + SCALE_LOG + } + + private var cachedBitmap: ImageBitmap? = null + private var cachedOffset: Int = -1 + + init { + initializeBytesArray() + } + + /** + * Updates internal bytes array by interpreting the given [SpectrumData] value + * + * @param fftResult Input [SpectrumData] + * @param mindB Min dB value to determine color coding + * @param rangedB Range dB value to determine color coding + * @param gain Additional gain to apply to input data + * @param sampleRate Sample rate (Hz) + */ + fun pushSpectrumData( + fftResult: SpectrumData, + mindB: Double, + rangedB: Double, + gain: Double, + ) { + // generate columns of pixels + // merge power of each frequencies following the destination bitmap resolution + val sampleRate = fftResult.sampleRate.toDouble() + val hertzBySpectrumCell = sampleRate / FFT_SIZE.toDouble() + val frequencyLegendPosition = when (scaleMode) { + ScaleMode.SCALE_LOG -> frequencyLegendPositionLog + else -> frequencyLegendPositionLinear + } + var lastProcessFrequencyIndex = 0 + val freqByPixel = fftResult.spectrum.size / size.height.toDouble() + for (pixel in 0.. by viewModel.spectrogramBitmapFlow + .collectAsState(emptyList()) + + Canvas(modifier = Modifier.fillMaxSize()) { + val spectrogramCanvasSize = IntSize( + (size.width - preparedSpectrogramOverlayBitmap.verticalLegendSize.width).toInt(), + (size.height - preparedSpectrogramOverlayBitmap.horizontalLegendSize.height).toInt() + ) + viewModel.updateCanvasSize(spectrogramCanvasSize) + + drawRect( + color = SpectrogramBitmap.colorRamp[0], + size = spectrogramCanvasSize.toSize() + ) + viewModel.currentStripData?.let { currentStripData -> + val offset = currentStripData.offset + spectrogramBitmaps.reversed().forEachIndexed { index, spectrogramBitmap -> + val bitmapX = size.width - + preparedSpectrogramOverlayBitmap.verticalLegendSize.width - + (index * SPECTROGRAM_STRIP_WIDTH + offset).toFloat() + drawImage( + spectrogramBitmap.toImageBitmap(), + topLeft = Offset(bitmapX, 0F) + ) + } + } + } + + Canvas(modifier = modifier.fillMaxSize()) { + if (preparedSpectrogramOverlayBitmap.imageSize != size) { + preparedSpectrogramOverlayBitmap = buildSpectrogramAxisBitmap( + size, + Density(density), + viewModel.scaleMode, + sampleRate, + textMeasurer, + colors + ) + } + drawImage(preparedSpectrogramOverlayBitmap.imageBitmap) + } +} + + +@Suppress("LongParameterList", "LongMethod") +private fun buildSpectrogramAxisBitmap( + size: Size, + density: Density, + scaleMode: SpectrogramBitmap.ScaleMode, + sampleRate: Double, + textMeasurer: TextMeasurer, + colors: ColorScheme, +): PlotBitmapOverlay { + val drawScope = CanvasDrawScope() + val bitmap = ImageBitmap(size.width.toInt(), size.height.toInt()) + val canvas = androidx.compose.ui.graphics.Canvas(bitmap) + + var frequencyLegendPosition = when (scaleMode) { + SpectrogramBitmap.ScaleMode.SCALE_LOG -> SpectrogramBitmap.frequencyLegendPositionLog + else -> SpectrogramBitmap.frequencyLegendPositionLinear + } + frequencyLegendPosition = frequencyLegendPosition + .filter { f -> f < sampleRate / 2 } + .toIntArray() + val timeXLabelMeasure = textMeasurer.measure(REFERENCE_LEGEND_TEXT) + val timeXLabelHeight = timeXLabelMeasure.size.height + val maxYLabelWidth = frequencyLegendPosition.maxOf { frequency -> + val text = frequency.toFrequencyString() + textMeasurer.measure(text).size.width + } + var bottomLegendSize = Size(0F, 0F) + var rightLegendSize = Size(0F, 0F) + drawScope.draw( + density = density, + layoutDirection = LayoutDirection.Ltr, + canvas = canvas, + size = size, + ) { + val axisBuilder = PlotAxisBuilder() + val legendHeight = timeXLabelHeight + axisBuilder.tickLength.toPx() + val legendWidth = maxYLabelWidth + axisBuilder.tickLength.toPx() + bottomLegendSize = Size(size.width - legendWidth, legendHeight) + rightLegendSize = Size(legendWidth, size.height - legendHeight) + if (sampleRate > 1) { + // draw Y axe labels + val fMax = sampleRate / 2 + val fMin = frequencyLegendPosition[0].toDouble() + val sheight = (size.height - legendHeight).toInt() + frequencyLegendPosition.forEach { frequency -> + val text = buildAnnotatedString { + withStyle(style = SpanStyle()) { + append(frequency.toFrequencyString()) + } + } + val textSize = textMeasurer.measure(text) + val tickHeightPos = when (scaleMode) { + SpectrogramBitmap.ScaleMode.SCALE_LOG -> { + sheight - (log10(frequency / fMin) / ((log10(fMax / fMin) / sheight))).toInt() + } + + else -> (sheight - frequency / fMax * sheight).toInt() + } + drawLine( + color = colors.onSurfaceVariant, start = Offset( + size.width - legendWidth, + tickHeightPos.toFloat() - axisBuilder.tickStroke.toPx() / 2 + ), + end = Offset( + size.width - legendWidth + axisBuilder.tickLength.toPx(), + tickHeightPos.toFloat() - axisBuilder.tickStroke.toPx() / 2 + ), + strokeWidth = axisBuilder.tickStroke.toPx() + ) + val textPos = min( + (size.height - textSize.size.height).toInt(), + max(0, tickHeightPos - textSize.size.height / 2) + ) + drawText( + textMeasurer, + text, + topLeft = Offset( + size.width - legendWidth + axisBuilder.tickLength.toPx(), + textPos.toFloat() + ) + ) + } + val xLegendWidth = (size.width - legendWidth) + val legendElements = axisBuilder.makeXLabels( + textMeasurer, + (FFT_HOP / sampleRate) * xLegendWidth, 0.0, + xLegendWidth, + axisBuilder::timeAxisFormater + ) + legendElements.forEach { legendElement -> + val tickPos = + max( + axisBuilder.tickStroke.toPx() / 2F, + min( + xLegendWidth - axisBuilder.tickStroke.toPx(), + legendElement.xPos - axisBuilder.tickStroke.toPx() / 2F + ) + ) + drawLine( + color = colors.onSurfaceVariant, start = Offset( + tickPos, + sheight.toFloat() + ), + end = Offset( + tickPos, + sheight + axisBuilder.tickLength.toPx() + ), + strokeWidth = axisBuilder.tickStroke.toPx() + ) + drawText( + legendElement.text, + topLeft = Offset( + legendElement.textPos, + sheight.toFloat() + axisBuilder.tickLength.toPx() + ) + ) + } + } + } + return PlotBitmapOverlay( + bitmap, + size, + bottomLegendSize, + rightLegendSize, + scaleMode.hashCode() + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrogram/SpectrogramPlotViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrogram/SpectrogramPlotViewModel.kt new file mode 100644 index 0000000..3a4ac68 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrogram/SpectrogramPlotViewModel.kt @@ -0,0 +1,107 @@ +package org.noiseplanet.noisecapture.ui.features.measurement.plot.spectrogram + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.unit.IntSize +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.noiseplanet.noisecapture.audio.ANDROID_GAIN +import org.noiseplanet.noisecapture.log.Logger +import org.noiseplanet.noisecapture.measurements.MeasurementsService + +class SpectrogramPlotViewModel( + private val measurementsService: MeasurementsService, + private val logger: Logger, +) : ViewModel() { + + companion object { + + private const val RANGE_DB = 40.0 + private const val MIN_DB = 0.0 + private const val DB_GAIN = ANDROID_GAIN // TODO: Platform dependant gain? + + const val REFERENCE_LEGEND_TEXT = " +99s " + const val SPECTROGRAM_STRIP_WIDTH = 32 + } + + private var canvasSize: IntSize = IntSize.Zero + private val spectrogramBitmaps = mutableStateListOf() + + val scaleMode = SpectrogramBitmap.ScaleMode.SCALE_LOG + + val sampleRateFlow: Flow = measurementsService + .getSpectrumDataFlow() + .map { it.sampleRate.toDouble() } + + val currentStripData: SpectrogramBitmap? + get() = spectrogramBitmaps.lastOrNull() + + val spectrogramBitmapFlow: StateFlow> + get() = MutableStateFlow(spectrogramBitmaps) + + init { + viewModelScope.launch { + // Listen to spectrum data updates and build spectrogram along the way + // TODO: We may want to pause this when the app goes into background + measurementsService.getSpectrumDataFlow() + .collect { spectrumData -> + currentStripData?.let { currentStripData -> + // Update current strip data + val newStripData = currentStripData.copy() + newStripData.pushSpectrumData( + spectrumData, MIN_DB, RANGE_DB, DB_GAIN + ) + spectrogramBitmaps[spectrogramBitmaps.size - 1] = newStripData + + if (currentStripData.offset == SPECTROGRAM_STRIP_WIDTH) { + // Spectrogram band complete, push new band to list + if ((spectrogramBitmaps.size - 1) * SPECTROGRAM_STRIP_WIDTH > canvasSize.width) { + // remove offscreen bitmaps + spectrogramBitmaps.removeAt(0) + } + withContext(Dispatchers.Main) { + spectrogramBitmaps.add( + SpectrogramBitmap( + size = IntSize( + width = SPECTROGRAM_STRIP_WIDTH, + height = canvasSize.height, + ), + scaleMode = scaleMode, + ) + ) + } + } + } + } + } + } + + /** + * Updates the canvas size used to generate spectrogram bitmaps. + * Should be called when screen size changes. + * + * @param newSize New canvas size. + */ + fun updateCanvasSize(newSize: IntSize) { + if (newSize == canvasSize) { + return + } + + logger.debug("Updating spectrogram canvas size: [W: ${newSize.width}, H: ${newSize.height}]") + + canvasSize = newSize + spectrogramBitmaps.clear() + spectrogramBitmaps.add( + SpectrogramBitmap( + size = canvasSize, + scaleMode = scaleMode, + ) + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrum/SpectrumPlotView.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrum/SpectrumPlotView.kt new file mode 100644 index 0000000..b349220 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrum/SpectrumPlotView.kt @@ -0,0 +1,241 @@ +package org.noiseplanet.noisecapture.ui.features.measurement.plot.spectrum + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import org.noiseplanet.noisecapture.ui.features.measurement.SPECTRUM_PLOT_SQUARE_OFFSET +import org.noiseplanet.noisecapture.ui.features.measurement.SPECTRUM_PLOT_SQUARE_WIDTH +import org.noiseplanet.noisecapture.ui.features.measurement.plot.PlotAxisBuilder +import org.noiseplanet.noisecapture.ui.features.measurement.plot.PlotBitmapOverlay +import org.noiseplanet.noisecapture.ui.features.measurement.plot.spectrum.SpectrumPlotViewModel.Companion.DBA_MAX +import org.noiseplanet.noisecapture.ui.features.measurement.plot.spectrum.SpectrumPlotViewModel.Companion.DBA_MIN +import org.noiseplanet.noisecapture.util.toFrequencyString +import kotlin.math.max +import kotlin.math.min + +@Composable +fun SpectrumPlotView( + viewModel: SpectrumPlotViewModel, + modifier: Modifier = Modifier, +) { + val surfaceColor = MaterialTheme.colorScheme.onSurface + + var preparedSpectrumOverlayBitmap = PlotBitmapOverlay( + ImageBitmap(1, 1), + Size(0F, 0F), + Size(0F, 0F), + Size(0F, 0F), + 0 + ) + + val rawSpl: DoubleArray by viewModel.rawSplFlow + .collectAsState(DoubleArray(0)) + val weightedSpl: DoubleArray by viewModel.weightedSplFlow + .collectAsState(DoubleArray(0)) + val axisSettings: SpectrumPlotViewModel.AxisSettings by viewModel.axisSettingsFlow + .collectAsState( + SpectrumPlotViewModel.AxisSettings(0.0, 0.0, emptyList()) + ) + + + Canvas(modifier) { + val pathEffect = PathEffect.dashPathEffect( + floatArrayOf( + SPECTRUM_PLOT_SQUARE_WIDTH.toPx(), + SPECTRUM_PLOT_SQUARE_OFFSET.toPx() + ) + ) + val axisBuilder = PlotAxisBuilder() + val weightedBarWidth = 10.dp.toPx() + val maxYAxisWidth = preparedSpectrumOverlayBitmap.verticalLegendSize.width + val barMaxWidth: Float = size.width - maxYAxisWidth + val maxXAxisHeight = preparedSpectrumOverlayBitmap.horizontalLegendSize.height + val chartHeight = (size.height - maxXAxisHeight - axisBuilder.tickLength.toPx()) + val barHeight = chartHeight / rawSpl.size - SPECTRUM_PLOT_SQUARE_OFFSET.toPx() + + rawSpl.forEachIndexed { index, spl -> + val barYOffset = + (barHeight + SPECTRUM_PLOT_SQUARE_OFFSET.toPx()) * (rawSpl.size - 1 - index) + val splRatio = (spl - DBA_MIN) / (DBA_MAX - DBA_MIN) + val splWeighted = max(spl, weightedSpl[index]) + val splWeightedRatio = min( + 1.0, + max( + 0.0, + (splWeighted - DBA_MIN) / (DBA_MAX - DBA_MIN) + ) + ) + val splGradient = + Brush.horizontalGradient( + *viewModel.spectrumColorRamp, + startX = 0F, + endX = size.width + ) + drawLine( + brush = splGradient, + start = Offset(maxYAxisWidth, barYOffset + barHeight / 2), + end = Offset( + max( + maxYAxisWidth, + ((barMaxWidth * splRatio).toFloat() + maxYAxisWidth) + ), + barYOffset + barHeight / 2 + ), + strokeWidth = barHeight, + pathEffect = pathEffect + ) + drawRect( + color = surfaceColor, + topLeft = Offset( + max( + maxYAxisWidth, + (barMaxWidth * splWeightedRatio).toFloat() - weightedBarWidth + maxYAxisWidth + ), barYOffset + ), + size = Size(weightedBarWidth, barHeight) + ) + } + } + + val colors = MaterialTheme.colorScheme + val textMeasurer = rememberTextMeasurer() + + Canvas(modifier = Modifier.fillMaxSize()) { + if (preparedSpectrumOverlayBitmap.imageSize != size || + preparedSpectrumOverlayBitmap.plotSettingsHashCode != axisSettings.hashCode() + ) { + preparedSpectrumOverlayBitmap = buildSpectrumAxisBitmap( + size, + Density(density), + axisSettings, + textMeasurer, + colors + ) + } + drawImage(preparedSpectrumOverlayBitmap.imageBitmap) + } +} + + +/** + * Generate bitmap of Axis (as it does not change between redraw of values) + */ +@Suppress("LongParameterList", "LongMethod") +private fun buildSpectrumAxisBitmap( + size: Size, + density: Density, + settings: SpectrumPlotViewModel.AxisSettings, + textMeasurer: TextMeasurer, + colors: ColorScheme, +): PlotBitmapOverlay { + val drawScope = CanvasDrawScope() + val bitmap = ImageBitmap(size.width.toInt(), size.height.toInt()) + val canvas = androidx.compose.ui.graphics.Canvas(bitmap) + val legendTexts = List(settings.nominalFrequencies.size) { frequencyIndex -> + val textLayoutResult = textMeasurer.measure(buildAnnotatedString { + withStyle( + SpanStyle( + fontSize = TextUnit( + 10F, + TextUnitType.Sp + ) + ) + ) { + append(settings.nominalFrequencies[frequencyIndex].toInt().toFrequencyString()) + } + }) + textLayoutResult + } + + var horizontalLegendSize = Size(0F, 0F) + var verticalLegendSize = Size(0F, 0F) + drawScope.draw( + density = density, + layoutDirection = LayoutDirection.Ltr, + canvas = canvas, + size = size, + ) { + val axisBuilder = PlotAxisBuilder() + val maxYAxisWidth = (legendTexts.maxOfOrNull { it.size.width }) ?: 0 + verticalLegendSize = Size(maxYAxisWidth.toFloat(), size.height) + val barMaxWidth: Float = size.width - maxYAxisWidth + val legendElements = axisBuilder.makeXLabels( + textMeasurer, settings.minimumX, settings.maximumX, barMaxWidth, + axisBuilder::noiseLevelAxisFormater + ) + val maxXAxisHeight = (legendElements.maxOfOrNull { it.text.size.height }) ?: 0 + horizontalLegendSize = Size(size.width, maxXAxisHeight.toFloat()) + val chartHeight = (size.height - maxXAxisHeight - axisBuilder.tickLength.toPx()) + legendElements.forEach { legendElement -> + val tickPos = + maxYAxisWidth + max( + axisBuilder.tickStroke.toPx() / 2F, + min( + barMaxWidth - axisBuilder.tickStroke.toPx(), + legendElement.xPos - axisBuilder.tickStroke.toPx() / 2F + ) + ) + drawLine( + color = colors.onSurfaceVariant, start = Offset( + tickPos, + chartHeight + ), + end = Offset( + tickPos, + chartHeight + axisBuilder.tickLength.toPx() + ), + strokeWidth = axisBuilder.tickStroke.toPx() + ) + drawText( + legendElement.text, + topLeft = Offset( + maxYAxisWidth + legendElement.textPos, + chartHeight + axisBuilder.tickLength.toPx() + ) + ) + } + val barHeight = + chartHeight / settings.nominalFrequencies.size - SPECTRUM_PLOT_SQUARE_OFFSET.toPx() + legendTexts.forEachIndexed { index, legendText -> + val barYOffset = + (barHeight + SPECTRUM_PLOT_SQUARE_OFFSET.toPx()) * (settings.nominalFrequencies.size - 1 - index) + drawText( + textMeasurer, + legendText.layoutInput.text, + topLeft = Offset( + 0F, + barYOffset + barHeight / 2 - legendText.size.height / 2F + ) + ) + } + } + return PlotBitmapOverlay( + bitmap, + size, + horizontalLegendSize, + verticalLegendSize, + settings.hashCode() + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrum/SpectrumPlotViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrum/SpectrumPlotViewModel.kt new file mode 100644 index 0000000..e7ac101 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/measurement/plot/spectrum/SpectrumPlotViewModel.kt @@ -0,0 +1,63 @@ +package org.noiseplanet.noisecapture.ui.features.measurement.plot.spectrum + +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import org.noiseplanet.noisecapture.measurements.MeasurementsService +import org.noiseplanet.noisecapture.util.toComposeColor +import kotlin.math.max + +class SpectrumPlotViewModel( + private val measurementsService: MeasurementsService, +) : ViewModel() { + + data class AxisSettings( + val minimumX: Double, + val maximumX: Double, + val nominalFrequencies: List, + ) + + companion object { + + const val DBA_MIN = 0.0 + const val DBA_MAX = 100.0 + + // TODO: Move this somewhere it can be shared between views like a themes file or smth + val noiseColorRampSpl: List> = listOf( + Pair(75F, "#FF0000".toComposeColor()), // >= 75 dB + Pair(65F, "#FF8000".toComposeColor()), // >= 65 dB + Pair(55F, "#FFFF00".toComposeColor()), // >= 55 dB + Pair(45F, "#99FF00".toComposeColor()), // >= 45 dB + Pair(Float.NEGATIVE_INFINITY, "#00FF00".toComposeColor()) + ) // < 45 dB + } + + // color ramp 0F left side of spectrum + // 1F right side of spectrum + val spectrumColorRamp = List(noiseColorRampSpl.size) { index -> + val pair = noiseColorRampSpl[noiseColorRampSpl.size - 1 - index] + val linearIndex = max(0.0, ((pair.first - DBA_MIN) / (DBA_MAX - DBA_MIN))) + Pair(linearIndex.toFloat(), pair.second) + }.toTypedArray() + + val rawSplFlow: Flow = measurementsService + .getAcousticIndicatorsFlow() + .map { it.thirdOctave } + + val weightedSplFlow: Flow = measurementsService + .getWeightedSoundPressureLevelFlow() + + val axisSettingsFlow: Flow = measurementsService + .getAcousticIndicatorsFlow() + .map { it.nominalFrequencies } + .distinctUntilChanged() + .map { + AxisSettings( + minimumX = DBA_MIN, + maximumX = DBA_MAX, + nominalFrequencies = it + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionModule.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionModule.kt new file mode 100644 index 0000000..880bd80 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionModule.kt @@ -0,0 +1,20 @@ +package org.noiseplanet.noisecapture.ui.features.permission + +import org.koin.compose.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.noiseplanet.noisecapture.permission.Permission +import org.noiseplanet.noisecapture.ui.features.permission.stateview.PermissionStateViewModel + +val requestPermissionModule = module { + viewModel { (permission: Permission) -> + PermissionStateViewModel( + permission = permission, + permissionService = get() + ) + } + viewModel { + RequestPermissionScreenViewModel( + permissionService = get() + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionScreen.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionScreen.kt new file mode 100644 index 0000000..c93167f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionScreen.kt @@ -0,0 +1,92 @@ +package org.noiseplanet.noisecapture.ui.features.permission + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import noisecapture.composeapp.generated.resources.Res +import noisecapture.composeapp.generated.resources.request_permission_button_next +import noisecapture.composeapp.generated.resources.request_permission_explanation +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.noiseplanet.noisecapture.ui.features.permission.stateview.PermissionStateView + +/** + * Presents required permissions to the user with controls to either request the + * permission if it was not yet asked, or to open the corresponding settings page + * if permission was already previously denied + * + * TODO: Instead of pushing this screen into the navigation stack, make it pop on top of + * the current screen when needed. Pressing the back button should also close the enclosing + * screen and pressing the next button should dismiss both screens. Maybe this can be done + * with a nested navigation controller? + */ +@Composable +fun RequestPermissionScreen( + onClickNextButton: () -> Unit, + viewModel: RequestPermissionScreenViewModel = koinInject(), + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues( + top = 16.dp, + bottom = 64.dp, + start = 16.dp, + end = 16.dp + ), + modifier = Modifier.fillMaxSize() + ) { + item { + Text( + text = stringResource(Res.string.request_permission_explanation), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + items(viewModel.permissionStateViewModels) { + PermissionStateView(it) + } + item { + // True if all required permissions have been granted + val allPermissionsGranted by viewModel.allPermissionsGranted + .collectAsState(false) + + AnimatedVisibility(allPermissionsGranted) { + // Show Next button only if all required permissions have been granted + Column(modifier = Modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.fillParentMaxWidth()) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Button(onClick = onClickNextButton) { + Text(stringResource(Res.string.request_permission_button_next)) + } + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionScreenViewModel.kt new file mode 100644 index 0000000..7505d93 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/RequestPermissionScreenViewModel.kt @@ -0,0 +1,38 @@ +package org.noiseplanet.noisecapture.ui.features.permission + +import androidx.lifecycle.ViewModel +import getPlatform +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.parameter.parametersOf +import org.noiseplanet.noisecapture.permission.PermissionService +import org.noiseplanet.noisecapture.permission.PermissionState +import org.noiseplanet.noisecapture.ui.features.permission.stateview.PermissionStateViewModel + +class RequestPermissionScreenViewModel( + private val permissionService: PermissionService, +) : ViewModel(), KoinComponent { + + private val requiredPermissions = getPlatform().requiredPermissions + + val permissionStateViewModels: List = requiredPermissions + .map { permission -> + get { parametersOf(permission) } + } + + /** + * Emits true if all required permissions have been granted by the user + */ + val allPermissionsGranted: Flow = combine( + requiredPermissions.map { permission -> + permissionService.getPermissionStateFlow(permission) + }, + transform = { + it.all { permission -> + permission == PermissionState.GRANTED + } + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/stateview/PermissionStateView.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/stateview/PermissionStateView.kt new file mode 100644 index 0000000..1196522 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/stateview/PermissionStateView.kt @@ -0,0 +1,86 @@ +package org.noiseplanet.noisecapture.ui.features.permission.stateview + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import noisecapture.composeapp.generated.resources.Res +import noisecapture.composeapp.generated.resources.request_permission_button_request +import noisecapture.composeapp.generated.resources.request_permission_button_settings +import org.jetbrains.compose.resources.stringResource + +/** + * Displays the current state of a system permission as well as controls to open settings or + * trigger a native permission request popup + */ +@Composable +fun PermissionStateView( + viewModel: PermissionStateViewModel, +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = viewModel.permissionName, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + + val icon by viewModel.stateIcon + .collectAsState(Icons.Default.BrokenImage) + val iconColor by viewModel.stateColor + .collectAsState(Color.Unspecified) + + Icon( + imageVector = icon, + tint = iconColor, + contentDescription = null, + modifier = Modifier.padding(horizontal = 8.dp) + ) + + Button( + onClick = { viewModel.openSettings() }, + ) { + Text( + text = stringResource(Res.string.request_permission_button_settings), + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + + val shouldShowRequestButton by viewModel.shouldShowRequestButton + .collectAsState(false) + // If permission state is not yet determined, show a button to trigger + // the permission request popup + AnimatedVisibility(shouldShowRequestButton) { + Button( + onClick = { viewModel.requestPermission() }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(Res.string.request_permission_button_request), + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/stateview/PermissionStateViewModel.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/stateview/PermissionStateViewModel.kt new file mode 100644 index 0000000..2ac497b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/features/permission/stateview/PermissionStateViewModel.kt @@ -0,0 +1,61 @@ +package org.noiseplanet.noisecapture.ui.features.permission.stateview + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.noiseplanet.noisecapture.permission.Permission +import org.noiseplanet.noisecapture.permission.PermissionService +import org.noiseplanet.noisecapture.permission.PermissionState + +class PermissionStateViewModel( + private val permission: Permission, + private val permissionService: PermissionService, +) : ViewModel() { + + val permissionName: String = permission.name + + val stateFlow: Flow = permissionService.getPermissionStateFlow(permission) + + val stateIcon: Flow = stateFlow.map { state -> + when (state) { + PermissionState.GRANTED -> Icons.Default.Check + PermissionState.DENIED -> Icons.Default.Close + else -> Icons.Default.QuestionMark + } + } + + val stateColor: Flow = stateFlow.map { state -> + when (state) { + PermissionState.GRANTED -> Color.Green + PermissionState.DENIED -> Color.Red + else -> Color.Gray + } + } + + val shouldShowRequestButton: Flow = stateFlow.map { state -> + state == PermissionState.NOT_DETERMINED + } + + fun openSettings() { + permissionService.openSettingsForPermission(permission) + } + + fun requestPermission() { + viewModelScope.launch { + // We want this to run in a background thread in order not to block UI updates + withContext(Dispatchers.Default) { + permissionService.requestPermission(permission) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/NavigationRoute.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/navigation/Route.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/NavigationRoute.kt rename to composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/navigation/Route.kt index 7fd7754..1d046aa 100644 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/NavigationRoute.kt +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/navigation/Route.kt @@ -1,4 +1,4 @@ -package org.noiseplanet.noisecapture.ui +package org.noiseplanet.noisecapture.ui.navigation import noisecapture.composeapp.generated.resources.Res import noisecapture.composeapp.generated.resources.app_name @@ -7,7 +7,7 @@ import noisecapture.composeapp.generated.resources.platform_info_title import noisecapture.composeapp.generated.resources.request_permission_title import org.jetbrains.compose.resources.StringResource -enum class NavigationRoute(val title: StringResource) { +enum class Route(val title: StringResource) { Home(title = Res.string.app_name), PlatformInfo(title = Res.string.platform_info_title), RequestPermission(title = Res.string.request_permission_title), diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/navigation/Transitions.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/navigation/Transitions.kt new file mode 100644 index 0000000..fc05474 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/navigation/Transitions.kt @@ -0,0 +1,47 @@ +package org.noiseplanet.noisecapture.ui.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.navigation.NavBackStackEntry + +private typealias TransitionCallback = + (AnimatedContentTransitionScope.() -> TransitionType) + +/** + * Transition animations to be used across this app. + */ +object Transitions { + + /** + * Duration of screen transitions in milliseconds + */ + private const val TRANSITION_DURATION = 300 + + val enterTransition: TransitionCallback = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Start, + tween(easing = FastOutSlowInEasing, durationMillis = TRANSITION_DURATION) + ) + } + val exitTransition: TransitionCallback = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Start, + tween(easing = FastOutSlowInEasing, durationMillis = TRANSITION_DURATION) + ) + } + val popEnterTransition: TransitionCallback = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.End, + tween(easing = FastOutSlowInEasing, durationMillis = TRANSITION_DURATION) + ) + } + val popExitTransition: TransitionCallback = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.End, + tween(easing = FastOutSlowInEasing, durationMillis = TRANSITION_DURATION) + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/screens/HomeScreen.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/screens/HomeScreen.kt deleted file mode 100644 index 240d38c..0000000 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/screens/HomeScreen.kt +++ /dev/null @@ -1,94 +0,0 @@ -package org.noiseplanet.noisecapture.ui.screens - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.material.Icon -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Help -import androidx.compose.material.icons.filled.CenterFocusWeak -import androidx.compose.material.icons.filled.History -import androidx.compose.material.icons.filled.HistoryEdu -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Map -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Timeline -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import noisecapture.composeapp.generated.resources.Res -import noisecapture.composeapp.generated.resources.menu_about -import noisecapture.composeapp.generated.resources.menu_calibration -import noisecapture.composeapp.generated.resources.menu_feedback -import noisecapture.composeapp.generated.resources.menu_help -import noisecapture.composeapp.generated.resources.menu_history -import noisecapture.composeapp.generated.resources.menu_map -import noisecapture.composeapp.generated.resources.menu_new_measurement -import noisecapture.composeapp.generated.resources.menu_settings -import noisecapture.composeapp.generated.resources.menu_statistics -import org.jetbrains.compose.resources.stringResource -import org.noiseplanet.noisecapture.ui.components.MenuItem - -/** - * Home screen layout. - * - * TODO: Improve UI once more clearly defined - * TODO: Figure out a clean design pattern to handle click events (delegate?, pass down navigation controller?) - */ -@Composable -fun HomeScreen( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val menuItems = arrayOf( - MenuItem( - stringResource(Res.string.menu_new_measurement), - Icons.Filled.Mic, - onClick = onClick - ), - MenuItem(stringResource(Res.string.menu_history), Icons.Filled.History) {}, - MenuItem(stringResource(Res.string.menu_feedback), Icons.Filled.HistoryEdu) {}, - MenuItem(stringResource(Res.string.menu_statistics), Icons.Filled.Timeline) {}, - MenuItem(stringResource(Res.string.menu_map), Icons.Filled.Map) {}, - MenuItem(stringResource(Res.string.menu_help), Icons.AutoMirrored.Filled.Help) {}, - MenuItem(stringResource(Res.string.menu_about), Icons.Filled.Info) {}, - MenuItem(stringResource(Res.string.menu_calibration), Icons.Filled.CenterFocusWeak) {}, - MenuItem(stringResource(Res.string.menu_settings), Icons.Filled.Settings) {}, - ) - - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 96.dp), - contentPadding = PaddingValues( - start = 24.dp, - top = 24.dp, - end = 24.dp, - bottom = 24.dp - ), - content = { - items(menuItems.size) { index -> - Button( - onClick = menuItems[index].onClick, - modifier = Modifier.aspectRatio(1f).padding(12.dp), - ) { - Icon( - imageVector = menuItems[index].imageVector, - menuItems[index].label, - modifier.fillMaxSize(), - ) - } - } - } - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/screens/MeasurementScreen.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/screens/MeasurementScreen.kt deleted file mode 100644 index b49380b..0000000 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/screens/MeasurementScreen.kt +++ /dev/null @@ -1,1076 +0,0 @@ -// -// -// TODO: Split this file!!!! -// -// -@file:Suppress("TooManyFunctions") - -package org.noiseplanet.noisecapture.ui.screens - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.PathEffect -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.drawscope.CanvasDrawScope -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextMeasurer -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.drawText -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.text.style.BaselineShift -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.TextUnitType -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.eventFlow -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import org.noiseplanet.noisecapture.audio.ANDROID_GAIN -import org.noiseplanet.noisecapture.audio.WINDOW_TIME -import org.noiseplanet.noisecapture.audio.signal.FAST_DECAY_RATE -import org.noiseplanet.noisecapture.audio.signal.LevelDisplayWeightedDecay -import org.noiseplanet.noisecapture.audio.signal.SpectrumData -import org.noiseplanet.noisecapture.measurements.FFT_HOP -import org.noiseplanet.noisecapture.measurements.MeasurementService -import org.noiseplanet.noisecapture.ui.components.measurement.LegendElement -import org.noiseplanet.noisecapture.ui.components.measurement.SpectrogramBitmap -import org.noiseplanet.noisecapture.ui.components.measurement.SpectrogramBitmap.Companion.toComposeColor -import org.noiseplanet.noisecapture.util.toImageBitmap -import kotlin.math.abs -import kotlin.math.log10 -import kotlin.math.max -import kotlin.math.min -import kotlin.math.pow -import kotlin.math.round - -const val SPECTROGRAM_STRIP_WIDTH = 32 -const val REFERENCE_LEGEND_TEXT = " +99s " -const val DEFAULT_SAMPLE_RATE = 48000.0 -const val MIN_SHOWN_DBA_VALUE = 5.0 -const val MAX_SHOWN_DBA_VALUE = 140.0 -const val MIN_SHOWN_DBA_VALUE_SPECTRUM = 0.0 -const val MAX_SHOWN_DBA_VALUE_SPECTRUM = 100.0 -val NOISE_LEVEL_FONT_SIZE = TextUnit(50F, TextUnitType.Sp) -val SPECTRUM_PLOT_SQUARE_WIDTH = 10.dp -val SPECTRUM_PLOT_SQUARE_OFFSET = 1.dp - -// TODO: Refactor this screen -@Suppress("LargeClass") -class MeasurementScreen( - private val measurementService: MeasurementService, -) { - - private var rangedB = 40.0 - private var mindB = 0.0 - private var dbGain = ANDROID_GAIN - - companion object { - - val noiseColorRampSpl: List> = listOf( - Pair(75F, "#FF0000".toComposeColor()), // >= 75 dB - Pair(65F, "#FF8000".toComposeColor()), // >= 65 dB - Pair(55F, "#FFFF00".toComposeColor()), // >= 55 dB - Pair(45F, "#99FF00".toComposeColor()), // >= 45 dB - Pair(Float.NEGATIVE_INFINITY, "#00FF00".toComposeColor()) - ) // < 45 dB - - fun timeAxisFormater(timeValue: Double): String { - return "+${round(timeValue).toInt()}s" - } - - fun noiseLevelAxisFormater(timeValue: Double): String { - return "${round(timeValue).toInt()} dB" - } - - val tickStroke = 2.dp - val tickLength = 4.dp - - // TODO: Cleanup legend generation functions - @Suppress("LongParameterList") - fun makeXLegend( - textMeasurer: TextMeasurer, - xValue: Double, - legendWidth: Float, - xPerPixel: Double, - depth: Int, - formater: (x: Double) -> String, - ascending: Boolean, - ): LegendElement { - val xPos = - when { - ascending -> (xValue / xPerPixel).toFloat() - else -> (legendWidth - xValue / xPerPixel).toFloat() - } - val legendText = buildAnnotatedString { - withStyle(style = SpanStyle()) { - append(formater(xValue)) - } - } - val textLayout = textMeasurer.measure(legendText) - val textPos = min( - legendWidth - textLayout.size.width, - max(0F, xPos - textLayout.size.width / 2) - ) - return LegendElement(textLayout, xPos, textPos, depth) - } - - // TODO: Cleanup legend generation functions - @Suppress("LongParameterList") - fun recursiveLegendBuild( - textMeasurer: TextMeasurer, - timeValue: Double, - legendWidth: Float, - timePerPixel: Double, - minPixel: Float, - maxPixel: Float, - xLeftValue: Double, - xRightValue: Double, - feedElements: ArrayList, - depth: Int, - formater: (x: Double) -> String, - ) { - val legendElement = - makeXLegend( - textMeasurer, - timeValue, - legendWidth, - timePerPixel, - depth, - formater, - xLeftValue < xRightValue - ) - // Add sub axis element if the text does not overlap with neighboring texts - if (legendElement.textPos > minPixel && legendElement.xPos + legendElement.text.size.width / 2 < maxPixel) { - feedElements.add(legendElement) - // left legend, + x seconds - recursiveLegendBuild( - textMeasurer, - xLeftValue + (timeValue - xLeftValue) / 2, - legendWidth, - timePerPixel, - minPixel, - legendElement.textPos, - xLeftValue, - timeValue, - feedElements, - depth + 1, - formater - ) - // right legend, - x seconds - recursiveLegendBuild( - textMeasurer, - timeValue + (xRightValue - timeValue) / 2, - legendWidth, - timePerPixel, - legendElement.textPos + legendElement.text.size.width, - maxPixel, - timeValue, - xRightValue, - feedElements, - depth + 1, - formater - ) - } - } - - fun makeXLabels( - textMeasurer: TextMeasurer, - leftValue: Double, - rightValue: Double, - xLegendWidth: Float, - formater: (x: Double) -> String, - ): ArrayList { - val xPerPixel = abs(leftValue - rightValue) / xLegendWidth - val legendElements = ArrayList() - val leftLegend = - makeXLegend( - textMeasurer, - leftValue, - xLegendWidth, - xPerPixel, - -1, - formater, - leftValue < rightValue - ) - val rightLegend = - makeXLegend( - textMeasurer, - rightValue, - xLegendWidth, - xPerPixel, - -1, - formater, - leftValue < rightValue - ) - legendElements.add(leftLegend) - legendElements.add(rightLegend) - // Add axis texts between left and rightmost axis texts (until it overlaps) - recursiveLegendBuild( - textMeasurer, - abs(leftValue - rightValue) / 2, - xLegendWidth, - xPerPixel, - leftLegend.text.size.width.toFloat(), - rightLegend.xPos - rightLegend.text.size.width, - leftValue, - rightValue, - legendElements, - 0, - formater - ) - // find depth index with maximum number of elements (to generate same intervals on legend) - val legendDepthCount = IntArray(legendElements.maxOf { it.depth } + 1) { 0 } - legendElements.forEach { - if (it.depth >= 0) { - legendDepthCount[it.depth] += 1 - } - } - // remove sub-axis texts with isolated depth (should produce same intervals between axis text) - legendElements.removeAll { - it.depth > 0 && legendDepthCount[it.depth] != (2.0.pow(it.depth)).toInt() - } - return legendElements - } - } - - - private val scaleMode = SpectrogramBitmap.Companion.ScaleMode.SCALE_LOG - val spectrumCanvasState = SpectrogramViewModel( - SpectrogramBitmap.SpectrogramDataModel( - IntSize(1, 1), - ByteArray(Int.SIZE_BYTES), 0, SpectrogramBitmap.Companion.ScaleMode.SCALE_LOG, 1.0 - ), ArrayList(), Size.Zero - ) - - var preparedSpectrogramOverlayBitmap = - PlotBitmapOverlay(ImageBitmap(1, 1), Size(0F, 0F), Size(0F, 0F), Size(0F, 0F), 0) - var preparedSpectrumOverlayBitmap = - PlotBitmapOverlay(ImageBitmap(1, 1), Size(0F, 0F), Size(0F, 0F), Size(0F, 0F), 0) - - @Composable - fun spectrogram(spectrumCanvasState: SpectrogramViewModel, bitmapOffset: Int) { - Canvas(modifier = Modifier.fillMaxSize()) { - val canvasSize = - IntSize( - SPECTROGRAM_STRIP_WIDTH, - (size.height - preparedSpectrogramOverlayBitmap.horizontalLegendSize.height).toInt() - ) - drawRect( - color = SpectrogramBitmap.colorRamp[0], - size = Size( - size.width - preparedSpectrogramOverlayBitmap.verticalLegendSize.width, - canvasSize.height.toFloat() - ) - ) - spectrumCanvasState.spectrogramCanvasSize = Size( - size.width - preparedSpectrogramOverlayBitmap.verticalLegendSize.width, size.height - - preparedSpectrogramOverlayBitmap.horizontalLegendSize.height - ) - if (spectrumCanvasState.currentStripData.size.height != canvasSize.height) { - // reset buffer on resize or first draw - println( - "Clear ${spectrumCanvasState.cachedStrips.size} strips " + - "${spectrumCanvasState.currentStripData.size.height} != ${canvasSize.height}" - ) - spectrumCanvasState.currentStripData = SpectrogramBitmap.createSpectrogram( - canvasSize, scaleMode, spectrumCanvasState.currentStripData.sampleRate - ) - spectrumCanvasState.cachedStrips.clear() - } else { - if (spectrumCanvasState.currentStripData.sampleRate > 1) { - drawImage( - spectrumCanvasState.currentStripData.byteArray.toImageBitmap(), - topLeft = Offset( - size.width - bitmapOffset - preparedSpectrogramOverlayBitmap.verticalLegendSize.width, - 0F - ) - ) - spectrumCanvasState.cachedStrips.reversed() - .forEachIndexed { index, imageBitmap -> - val bitmapX = size.width - - preparedSpectrogramOverlayBitmap.verticalLegendSize.width - - ((index + 1) * SPECTROGRAM_STRIP_WIDTH + bitmapOffset).toFloat() - drawImage( - imageBitmap, - topLeft = Offset(bitmapX, 0F) - ) - } - } - } - } - } - - /** - * Generate bitmap of Axis (as it does not change between redraw of values) - */ - @Suppress("LongParameterList", "LongMethod") - fun buildSpectrumAxisBitmap( - size: Size, density: Density, - settings: SpectrumSettings, - textMeasurer: TextMeasurer, - colors: ColorScheme, - ): PlotBitmapOverlay { - val drawScope = CanvasDrawScope() - val bitmap = ImageBitmap(size.width.toInt(), size.height.toInt()) - val canvas = androidx.compose.ui.graphics.Canvas(bitmap) - val legendTexts = List(settings.nominalFrequencies.size) { frequencyIndex -> - val textLayoutResult = textMeasurer.measure(buildAnnotatedString { - withStyle( - SpanStyle( - fontSize = TextUnit( - 10F, - TextUnitType.Sp - ) - ) - ) - { append(formatFrequency(settings.nominalFrequencies[frequencyIndex].toInt())) } - }) - textLayoutResult - } - - var horizontalLegendSize = Size(0F, 0F) - var verticalLegendSize = Size(0F, 0F) - drawScope.draw( - density = density, - layoutDirection = LayoutDirection.Ltr, - canvas = canvas, - size = size, - ) { - val maxYAxisWidth = (legendTexts.maxOfOrNull { it.size.width }) ?: 0 - verticalLegendSize = Size(maxYAxisWidth.toFloat(), size.height) - val barMaxWidth: Float = size.width - maxYAxisWidth - val legendElements = makeXLabels( - textMeasurer, settings.minimumX, settings.maximumX, barMaxWidth, - ::noiseLevelAxisFormater - ) - val maxXAxisHeight = (legendElements.maxOfOrNull { it.text.size.height }) ?: 0 - horizontalLegendSize = Size(size.width, maxXAxisHeight.toFloat()) - val chartHeight = (size.height - maxXAxisHeight - tickLength.toPx()) - legendElements.forEach { legendElement -> - val tickPos = - maxYAxisWidth + max( - tickStroke.toPx() / 2F, - min( - barMaxWidth - tickStroke.toPx(), - legendElement.xPos - tickStroke.toPx() / 2F - ) - ) - drawLine( - color = colors.onSurfaceVariant, start = Offset( - tickPos, - chartHeight - ), - end = Offset( - tickPos, - chartHeight + tickLength.toPx() - ), - strokeWidth = tickStroke.toPx() - ) - drawText( - legendElement.text, - topLeft = Offset( - maxYAxisWidth + legendElement.textPos, - chartHeight + tickLength.toPx() - ) - ) - } - val barHeight = chartHeight / settings.nominalFrequencies.size - SPECTRUM_PLOT_SQUARE_OFFSET.toPx() - legendTexts.forEachIndexed { index, legendText -> - val barYOffset = - (barHeight + SPECTRUM_PLOT_SQUARE_OFFSET.toPx()) * (settings.nominalFrequencies.size - 1 - index) - drawText( - textMeasurer, - legendText.layoutInput.text, - topLeft = Offset( - 0F, - barYOffset + barHeight / 2 - legendText.size.height / 2F - ) - ) - } - } - return PlotBitmapOverlay( - bitmap, - size, - horizontalLegendSize, - verticalLegendSize, - settings.hashCode() - ) - } - - @Suppress("LongParameterList", "LongMethod") - fun buildSpectrogramAxisBitmap( - size: Size, - density: Density, - scaleMode: SpectrogramBitmap.Companion.ScaleMode, - sampleRate: Double, - textMeasurer: TextMeasurer, - colors: ColorScheme, - ): PlotBitmapOverlay { - val drawScope = CanvasDrawScope() - val bitmap = ImageBitmap(size.width.toInt(), size.height.toInt()) - val canvas = androidx.compose.ui.graphics.Canvas(bitmap) - - var frequencyLegendPosition = when (scaleMode) { - SpectrogramBitmap.Companion.ScaleMode.SCALE_LOG -> SpectrogramBitmap.frequencyLegendPositionLog - else -> SpectrogramBitmap.frequencyLegendPositionLinear - } - frequencyLegendPosition = - frequencyLegendPosition.filter { f -> f < sampleRate / 2 }.toIntArray() - val timeXLabelMeasure = textMeasurer.measure(REFERENCE_LEGEND_TEXT) - val timeXLabelHeight = timeXLabelMeasure.size.height - val maxYLabelWidth = - frequencyLegendPosition.maxOf { frequency -> - val text = formatFrequency(frequency) - textMeasurer.measure(text).size.width - } - var bottomLegendSize = Size(0F, 0F) - var rightLegendSize = Size(0F, 0F) - drawScope.draw( - density = density, - layoutDirection = LayoutDirection.Ltr, - canvas = canvas, - size = size, - ) { - val legendHeight = timeXLabelHeight + tickLength.toPx() - val legendWidth = maxYLabelWidth + tickLength.toPx() - bottomLegendSize = Size(size.width - legendWidth, legendHeight) - rightLegendSize = Size(legendWidth, size.height - legendHeight) - if (sampleRate > 1) { - // draw Y axe labels - val fMax = sampleRate / 2 - val fMin = frequencyLegendPosition[0].toDouble() - val sheight = (size.height - legendHeight).toInt() - frequencyLegendPosition.forEachIndexed { index, frequency -> - val text = buildAnnotatedString { - withStyle(style = SpanStyle()) { - append(formatFrequency(frequency)) - } - } - val textSize = textMeasurer.measure(text) - val tickHeightPos = when (scaleMode) { - SpectrogramBitmap.Companion.ScaleMode.SCALE_LOG -> { - sheight - (log10(frequency / fMin) / ((log10(fMax / fMin) / sheight))).toInt() - } - - else -> (sheight - frequency / fMax * sheight).toInt() - } - drawLine( - color = colors.onSurfaceVariant, start = Offset( - size.width - legendWidth, - tickHeightPos.toFloat() - tickStroke.toPx() / 2 - ), - end = Offset( - size.width - legendWidth + tickLength.toPx(), - tickHeightPos.toFloat() - tickStroke.toPx() / 2 - ), - strokeWidth = tickStroke.toPx() - ) - val textPos = min( - (size.height - textSize.size.height).toInt(), - max(0, tickHeightPos - textSize.size.height / 2) - ) - drawText( - textMeasurer, - text, - topLeft = Offset( - size.width - legendWidth + tickLength.toPx(), - textPos.toFloat() - ) - ) - } - val xLegendWidth = (size.width - legendWidth) - val legendElements = makeXLabels( - textMeasurer, (FFT_HOP / sampleRate) * xLegendWidth, 0.0, - xLegendWidth, ::timeAxisFormater - ) - legendElements.forEach { legendElement -> - val tickPos = - max( - tickStroke.toPx() / 2F, - min( - xLegendWidth - tickStroke.toPx(), - legendElement.xPos - tickStroke.toPx() / 2F - ) - ) - drawLine( - color = colors.onSurfaceVariant, start = Offset( - tickPos, - sheight.toFloat() - ), - end = Offset( - tickPos, - sheight + tickLength.toPx() - ), - strokeWidth = tickStroke.toPx() - ) - drawText( - legendElement.text, - topLeft = Offset( - legendElement.textPos, - sheight.toFloat() + tickLength.toPx() - ) - ) - } - } - } - return PlotBitmapOverlay( - bitmap, - size, - bottomLegendSize, - rightLegendSize, - scaleMode.hashCode() - ) - } - - @Composable - fun spectrumAxis( - settings: SpectrumSettings - ) { - val colors = MaterialTheme.colorScheme - val textMeasurer = rememberTextMeasurer() - Canvas(modifier = Modifier.fillMaxSize()) { - if (preparedSpectrumOverlayBitmap.imageSize != size || - preparedSpectrumOverlayBitmap.plotSettingsHashCode != settings.hashCode() - ) { - preparedSpectrumOverlayBitmap = buildSpectrumAxisBitmap( - size, - Density(density), - settings, - textMeasurer, - colors - ) - } - drawImage(preparedSpectrumOverlayBitmap.imageBitmap) - } - } - - @Composable - fun spectrogramAxis(scaleMode: SpectrogramBitmap.Companion.ScaleMode, sampleRate: Double) { - val colors = MaterialTheme.colorScheme - val textMeasurer = rememberTextMeasurer() - Canvas(modifier = Modifier.fillMaxSize()) { - if (preparedSpectrogramOverlayBitmap.imageSize != size) { - preparedSpectrogramOverlayBitmap = buildSpectrogramAxisBitmap( - size, Density(density), scaleMode, - sampleRate, textMeasurer, colors - ) - } - drawImage(preparedSpectrogramOverlayBitmap.imageBitmap) - } - } - - - fun formatFrequency(frequency: Int): String { - return if (frequency >= 1000) { - if (frequency % 1000 > 0) { - val subKilo = (frequency % 1000).toString().trimEnd('0') - "${frequency / 1000}.$subKilo kHz" - } else { - "${frequency / 1000} kHz" - } - } else { - "$frequency Hz" - } - } - - fun processSpectrum(spectrumCanvasState: SpectrogramViewModel, it: SpectrumData): Int { - spectrumCanvasState.currentStripData.pushSpectrumToSpectrogramData( - it, mindB, rangedB, - dbGain - ) - if (spectrumCanvasState.currentStripData.offset == SPECTROGRAM_STRIP_WIDTH) { - // spectrogram band complete, store bitmap - spectrumCanvasState.cachedStrips.add( - spectrumCanvasState.currentStripData.byteArray.toImageBitmap() - ) - if ((spectrumCanvasState.cachedStrips.size - 1) * - SPECTROGRAM_STRIP_WIDTH > - spectrumCanvasState.spectrogramCanvasSize.width - ) { - // remove offscreen bitmaps - spectrumCanvasState.cachedStrips.removeAt(0) - } - spectrumCanvasState.currentStripData = - SpectrogramBitmap.createSpectrogram( - spectrumCanvasState.currentStripData.size, - spectrumCanvasState.currentStripData.scaleMode, - it.sampleRate.toDouble() - ) - } - return spectrumCanvasState.currentStripData.offset - } - - @Composable - fun buildNoiseLevelText(noiseLevel: Double): AnnotatedString = buildAnnotatedString { - val inRangeNoise = noiseLevel > MIN_SHOWN_DBA_VALUE && noiseLevel < MAX_SHOWN_DBA_VALUE - val colorIndex = noiseColorRampSpl.indexOfFirst { pair -> pair.first < noiseLevel } - withStyle( - style = SpanStyle( - color = if (inRangeNoise) noiseColorRampSpl[colorIndex].second else MaterialTheme.colorScheme.onPrimary, - fontSize = NOISE_LEVEL_FONT_SIZE, - baselineShift = BaselineShift.None - ) - ) { - when { - inRangeNoise -> append("${round(noiseLevel * 10) / 10}") - else -> append("-") - } - } - } - - @Suppress("LongParameterList", "LongMethod", "SpreadOperator") - @Composable - fun spectrumPlot( - modifier: Modifier, - settings: SpectrumSettings, - values: SpectrumPlotData, - ) { - val surfaceColor = MaterialTheme.colorScheme.onSurface - // color ramp 0F left side of spectrum - // 1F right side of spectrum - val spectrumColorRamp = remember(settings) { - List(noiseColorRampSpl.size) { index -> - val pair = noiseColorRampSpl[noiseColorRampSpl.size - 1 - index] - val linearIndex = max( - 0.0, ((pair.first - settings.minimumX) / - (settings.maximumX - settings.minimumX)) - ) - Pair(linearIndex.toFloat(), pair.second) - }.toTypedArray() - } - Canvas(modifier) { - val pathEffect = PathEffect.dashPathEffect( - floatArrayOf( - SPECTRUM_PLOT_SQUARE_WIDTH.toPx(), - SPECTRUM_PLOT_SQUARE_OFFSET.toPx() - ) - ) - val weightedBarWidth = 10.dp.toPx() - val maxYAxisWidth = preparedSpectrumOverlayBitmap.verticalLegendSize.width - val barMaxWidth: Float = size.width - maxYAxisWidth - val maxXAxisHeight = preparedSpectrumOverlayBitmap.horizontalLegendSize.height - val chartHeight = (size.height - maxXAxisHeight - tickLength.toPx()) - val barHeight = chartHeight / values.spl.size - SPECTRUM_PLOT_SQUARE_OFFSET.toPx() - values.spl.forEachIndexed { index, spl -> - val barYOffset = - (barHeight + SPECTRUM_PLOT_SQUARE_OFFSET.toPx()) * (values.spl.size - 1 - index) - val splRatio = (spl - settings.minimumX) / (settings.maximumX - settings.minimumX) - val splWeighted = max(spl, values.splWeighted[index]) - val splWeightedRatio = min( - 1.0, - max( - 0.0, - (splWeighted - settings.minimumX) / (settings.maximumX - settings.minimumX) - ) - ) - val splGradient = - Brush.horizontalGradient(*spectrumColorRamp, startX = 0F, endX = size.width) - drawLine( - brush = splGradient, - start = Offset(maxYAxisWidth, barYOffset + barHeight / 2), - end = Offset( - max( - maxYAxisWidth, - ((barMaxWidth * splRatio).toFloat() + maxYAxisWidth) - ), - barYOffset + barHeight / 2 - ), - strokeWidth = barHeight, - pathEffect = pathEffect - ) - drawRect( - color = surfaceColor, topLeft = Offset( - max( - maxYAxisWidth, - (barMaxWidth * splWeightedRatio).toFloat() - weightedBarWidth + maxYAxisWidth - ), barYOffset - ), - size = Size(weightedBarWidth, barHeight) - ) - } - } - } - - @Composable - fun vueMeter(modifier: Modifier, settings: VueMeterSettings, value: Double) { - val color = MaterialTheme.colorScheme - val textMeasurer = rememberTextMeasurer() - Canvas(modifier = modifier) { - // x axis labels - var maxHeight = 0 - settings.xLabels.forEach { value -> - val textLayoutResult = textMeasurer.measure(buildAnnotatedString { - withStyle( - SpanStyle( - fontSize = TextUnit( - 10F, - TextUnitType.Sp - ) - ) - ) - { append("$value") } - }) - maxHeight = max(textLayoutResult.size.height, maxHeight) - val labelRatio = - max(0.0, (value - settings.minimum) / (settings.maximum - settings.minimum)) - val xPosition = min( - size.width - textLayoutResult.size.width, - max( - 0F, - (size.width * labelRatio - textLayoutResult.size.width / 2).toFloat() - ) - ) - drawText(textLayoutResult, topLeft = Offset(xPosition, 0F)) - } - val barHeight = size.height - maxHeight - drawRoundRect( - color = color.background, - topLeft = Offset(0F, maxHeight.toFloat()), - cornerRadius = CornerRadius(barHeight / 2, barHeight / 2), - size = Size(size.width, barHeight) - ) - val valueRatio = (value - settings.minimum) / (settings.maximum - settings.minimum) - val colorIndex = noiseColorRampSpl.indexOfFirst { pair -> pair.first < value } - drawRoundRect( - color = noiseColorRampSpl[colorIndex].second, - topLeft = Offset(0F, maxHeight.toFloat()), - cornerRadius = CornerRadius(barHeight / 2, barHeight / 2), - size = Size((size.width * valueRatio).toFloat(), barHeight) - ) - } - } - - @Suppress("LongParameterList", "LongMethod") - @Composable - fun measurementHeader(noiseLevel: Double) { - val rightRoundedSquareShape: Shape = RoundedCornerShape( - topStart = 0.dp, - topEnd = 40.dp, - bottomStart = 0.dp, - bottomEnd = 40.dp - ) - val vueMeterSettings = VueMeterSettings(20.0, 120.0, - IntArray(6) { v -> ((v + 1) * 20.0).toInt() }) - Column() { - Row( - horizontalArrangement = Arrangement.SpaceBetween - ) { - Surface( - Modifier.padding(top = 20.dp, bottom = 10.dp).weight(1F), - color = MaterialTheme.colorScheme.background, - shape = rightRoundedSquareShape, - shadowElevation = 10.dp - ) { - Row( - modifier = Modifier.padding(10.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - buildAnnotatedString { - withStyle( - SpanStyle( - fontSize = TextUnit( - 18F, - TextUnitType.Sp - ), - ) - ) - { append("dB(A)") } - }, - modifier = Modifier.align(Alignment.CenterVertically) - ) - Text( - buildNoiseLevelText(noiseLevel), - modifier = Modifier.align(Alignment.CenterVertically) - ) - } - } - Row( - Modifier.align(Alignment.CenterVertically), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - listOf( - MeasurementStatistics("Min", "-"), - MeasurementStatistics("Avg", "-"), - MeasurementStatistics("Max", "-") - ).forEach { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(10.dp) - ) { - Text(it.label) - Text(it.value) - } - } - } - } - - vueMeter( - Modifier.fillMaxWidth().height(50.dp).padding(start = 30.dp, end = 30.dp), - vueMeterSettings, - noiseLevel - ) - } - } - - @OptIn(ExperimentalFoundationApi::class) - @Composable - fun measurementPager( - bitmapOffset: Int, - sampleRate: Double, - spectrumData: SpectrumPlotData, - spectrumSettings: SpectrumSettings, - ) { - - val animationScope = rememberCoroutineScope() - val pagerState = rememberPagerState(pageCount = { MeasurementTabState.entries.size }) - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - TabRow(selectedTabIndex = pagerState.currentPage) { - MeasurementTabState.entries.forEach { entry -> - Tab( - text = { Text(MEASUREMENT_TAB_LABEL[entry.ordinal]) }, - selected = pagerState.currentPage == entry.ordinal, - onClick = { animationScope.launch { pagerState.animateScrollToPage(entry.ordinal) } } - ) - } - } - HorizontalPager(state = pagerState) { page -> - when (MeasurementTabState.entries[page]) { - MeasurementTabState.SPECTROGRAM -> Box(Modifier.fillMaxSize()) { - spectrogram(spectrumCanvasState, bitmapOffset) - spectrogramAxis(scaleMode, sampleRate) - } - - MeasurementTabState.SPECTRUM -> Box(Modifier.fillMaxSize()) { - spectrumPlot(Modifier.fillMaxSize(), spectrumSettings, spectrumData) - spectrumAxis(spectrumSettings) - } - - else -> Surface( - Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - Text( - text = "Text tab ${MEASUREMENT_TAB_LABEL[page]} selected", - style = MaterialTheme.typography.bodyLarge - ) - } - } - } - } - } - - - @OptIn(ExperimentalFoundationApi::class) - @Suppress("LongParameterList", "LongMethod") - @Composable - fun Content( - lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, - ) { - var bitmapOffset by remember { mutableStateOf(0) } - var noiseLevel by remember { mutableStateOf(0.0) } - var sampleRate by remember { mutableStateOf(DEFAULT_SAMPLE_RATE) } - var spectrumDataState by remember { - mutableStateOf( - SpectrumPlotData( - ArrayList(0), - DoubleArray(0), DoubleArray(0) - ) - ) - } - var spectrumSettings by remember { - mutableStateOf( - SpectrumSettings( - MIN_SHOWN_DBA_VALUE_SPECTRUM, - MAX_SHOWN_DBA_VALUE_SPECTRUM, - ArrayList(0) - ) - ) - } - var indicatorCollectJob: Job? = null - var spectrumCollectJob: Job? = null - val launchMeasurementJob = fun() { - indicatorCollectJob = lifecycleOwner.lifecycleScope.launch { - val levelDisplay = LevelDisplayWeightedDecay(FAST_DECAY_RATE, WINDOW_TIME) - var levelDisplayBands: Array? = null - measurementService.collectAudioIndicators().collect { - if (levelDisplayBands == null) { - levelDisplayBands = - Array(it.nominalFrequencies.size) { - LevelDisplayWeightedDecay( - FAST_DECAY_RATE, - WINDOW_TIME - ) - } - } - noiseLevel = levelDisplay.getWeightedValue(it.laeq) - val splWeightedArray = - DoubleArray(it.nominalFrequencies.size) { index -> - levelDisplayBands!![index].getWeightedValue(it.thirdOctave[index]) - } - spectrumDataState = - SpectrumPlotData(it.nominalFrequencies, it.thirdOctave, splWeightedArray) - spectrumSettings = SpectrumSettings( - MIN_SHOWN_DBA_VALUE_SPECTRUM, - MAX_SHOWN_DBA_VALUE_SPECTRUM, - it.nominalFrequencies - ) - } - } - spectrumCollectJob = lifecycleOwner.lifecycleScope.launch { - println("Launch spectrum lifecycle") - measurementService.collectSpectrumData().collect() { spectrumData -> - sampleRate = spectrumData.sampleRate.toDouble() - if (spectrumCanvasState.currentStripData.size.width > 1) { - bitmapOffset = processSpectrum(spectrumCanvasState, spectrumData) - } - } - } - } - launchMeasurementJob() - lifecycleOwner.lifecycleScope.launch { - lifecycleOwner.lifecycle.eventFlow.collect { event -> - if (event == Lifecycle.Event.ON_PAUSE) { - indicatorCollectJob?.cancel() - spectrumCollectJob?.cancel() - } else if (event == Lifecycle.Event.ON_RESUME && - (indicatorCollectJob == null || indicatorCollectJob?.isActive == false) - ) { - launchMeasurementJob() - } - } - } - - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - BoxWithConstraints { - if (maxWidth > maxHeight) { - Row(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxWidth(.5F)) { - measurementHeader(noiseLevel) - } - Column(modifier = Modifier) { - measurementPager( - bitmapOffset, - sampleRate, - spectrumDataState, - spectrumSettings - ) - } - } - } else { - Column(modifier = Modifier.fillMaxSize()) { - measurementHeader(noiseLevel) - measurementPager( - bitmapOffset, - sampleRate, - spectrumDataState, - spectrumSettings - ) - } - } - } - } - } -} - -data class SpectrogramViewModel( - var currentStripData: SpectrogramBitmap.SpectrogramDataModel, - val cachedStrips: ArrayList, - var spectrogramCanvasSize: Size, -) - -enum class MeasurementTabState { SPECTRUM, - SPECTROGRAM, - MAP -} - -val MEASUREMENT_TAB_LABEL = listOf("Spectrum", "Spectrogram", "Map") - -data class PlotBitmapOverlay( - val imageBitmap: ImageBitmap, - val imageSize: Size, - val horizontalLegendSize: Size, - val verticalLegendSize: Size, - val plotSettingsHashCode: Int, -) - -data class MeasurementStatistics(val label: String, val value: String) - -data class SpectrumSettings( - val minimumX: Double, - val maximumX: Double, - val nominalFrequencies: List, -) - -data class SpectrumPlotData( - val nominalFrequencies: List, - val spl: DoubleArray, - val splWeighted: DoubleArray, -) - -data class VueMeterSettings(val minimum: Double, val maximum: Double, val xLabels: IntArray) { - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as VueMeterSettings - - if (minimum != other.minimum) return false - if (maximum != other.maximum) return false - if (!xLabels.contentEquals(other.xLabels)) return false - - return true - } - - override fun hashCode(): Int { - var result = minimum.hashCode() - result = 31 * result + maximum.hashCode() - result = 31 * result + xLabels.contentHashCode() - return result - } -} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/screens/PlatformInfoScreen.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/screens/PlatformInfoScreen.kt deleted file mode 100644 index ec8b818..0000000 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/screens/PlatformInfoScreen.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.noiseplanet.noisecapture.ui.screens - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import noisecapture.composeapp.generated.resources.Res -import noisecapture.composeapp.generated.resources.compose_multiplatform -import org.jetbrains.compose.resources.painterResource -import org.noiseplanet.noisecapture.ui.components.Greeting - -/** - * Gives information about the platform the app is currently running on. - * Not aimed to be kept in the end but for now serves as a practical example of - * platform specific implementations - */ -@Composable -fun PlatformInfoScreen( - modifier: Modifier = Modifier, -) { - // A platform specific greeting message - val greeting = remember { Greeting().greet() } - - Column( - modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image(painterResource(Res.drawable.compose_multiplatform), null) - Text("Compose: $greeting") - } -} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/screens/RequestPermissionScreen.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/screens/RequestPermissionScreen.kt deleted file mode 100644 index 0456ca9..0000000 --- a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/ui/screens/RequestPermissionScreen.kt +++ /dev/null @@ -1,175 +0,0 @@ -package org.noiseplanet.noisecapture.ui.screens - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.QuestionMark -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import getPlatform -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch -import noisecapture.composeapp.generated.resources.Res -import noisecapture.composeapp.generated.resources.request_permission_button_next -import noisecapture.composeapp.generated.resources.request_permission_button_request -import noisecapture.composeapp.generated.resources.request_permission_button_settings -import noisecapture.composeapp.generated.resources.request_permission_explanation -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.koinInject -import org.noiseplanet.noisecapture.permission.PermissionService -import org.noiseplanet.noisecapture.permission.PermissionState - -/** - * Presents required permissions to the user with controls to either request the - * permission if it was not yet asked, or to open the corresponding settings page - * if permission was already previously denied - * - * TODO: Use view models to provide data to the interface - * TODO: Rethink package structure to split views into smaller components - */ -@Composable -fun RequestPermissionScreen( - onClickNextButton: () -> Unit, - permissionService: PermissionService = koinInject(), - modifier: Modifier = Modifier, -) { - val coroutineScope = rememberCoroutineScope() - - Surface( - modifier = modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = PaddingValues( - top = 16.dp, - bottom = 64.dp, - start = 16.dp, - end = 16.dp - ), - modifier = Modifier.fillMaxSize() - ) { - item { - Text( - text = stringResource(Res.string.request_permission_explanation), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - - val requiredPermissions = getPlatform().requiredPermissions - items(requiredPermissions) { permission -> - val permissionState: PermissionState by permissionService - .getPermissionStateFlow(permission) - .collectAsState(PermissionState.NOT_DETERMINED) - - Column( - modifier = Modifier.fillMaxWidth() - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = permission.name, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) - ) - Icon( - imageVector = when (permissionState) { - PermissionState.GRANTED -> Icons.Default.Check - PermissionState.NOT_DETERMINED -> Icons.Default.QuestionMark - else -> Icons.Default.Close - }, - tint = when (permissionState) { - PermissionState.GRANTED -> Color.Green - PermissionState.NOT_DETERMINED -> Color.Gray - else -> Color.Red - }, - contentDescription = null, - modifier = Modifier.padding(horizontal = 8.dp) - ) - Button( - onClick = { - permissionService.openSettingsForPermission(permission) - }, - ) { - Text( - text = stringResource(Res.string.request_permission_button_settings), - color = MaterialTheme.colorScheme.onPrimary, - ) - } - } - - // If permission state is not yet determined, show a button to trigger - // the permission request popup - AnimatedVisibility(permissionState == PermissionState.NOT_DETERMINED) { - Button( - onClick = { - coroutineScope.launch { - permissionService.requestPermission(permission) - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(Res.string.request_permission_button_request), - color = MaterialTheme.colorScheme.onPrimary, - ) - } - } - } - } - item { - // True if all required permissions have been granted - val allPermissionsGranted by combine( - requiredPermissions.map { permission -> - permissionService.getPermissionStateFlow(permission) - }, - transform = { - it.all { permission -> - permission == PermissionState.GRANTED - } - } - ).collectAsState(false) - - AnimatedVisibility(allPermissionsGranted) { - // Show Next button only if all required permissions have been granted - Column(modifier = Modifier.fillMaxWidth()) { - Spacer(modifier = Modifier.fillParentMaxWidth()) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - Button(onClick = onClickNextButton) { - Text(stringResource(Res.string.request_permission_button_next)) - } - } - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/ColorUtil.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/ColorUtil.kt new file mode 100644 index 0000000..9de27c1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/ColorUtil.kt @@ -0,0 +1,33 @@ +package org.noiseplanet.noisecapture.util + +import androidx.compose.ui.graphics.Color + +/** + * Parses the given color string as an ARGB color integer + * + * TODO: Add unit tests + * + * @param colorString Input color string + * @return ARGB color integer + */ +fun parseColor(colorString: String): Int { + var color = colorString.substring(1).toLong(16) + if (colorString.length == 7) { + // Set the alpha value + color = color or 0x00000000ff000000L + } else { + require(colorString.length != 9) { "Unknown color" } + } + return color.toInt() +} + +/** + * Tries to interpret this string as a compose Color. + * + * TODO: Add Unit tests + * + * @return Parsed [Color] object + */ +fun String.toComposeColor(): Color { + return Color(parseColor(this)) +} diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/EndiannessUtil.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/EndiannessUtil.kt new file mode 100644 index 0000000..a82f906 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/EndiannessUtil.kt @@ -0,0 +1,13 @@ +package org.noiseplanet.noisecapture.util + +/** + * Convert Int into little endian array of bytes + * + * // TODO: Add unit tests + */ +fun Int.toLittleEndianBytes(): ByteArray = byteArrayOf( + this.toByte(), + this.ushr(8).toByte(), + this.ushr(16).toByte(), + this.ushr(24).toByte() +) diff --git a/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/FrequencyToString.kt b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/FrequencyToString.kt new file mode 100644 index 0000000..8eb7237 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/noiseplanet/noisecapture/util/FrequencyToString.kt @@ -0,0 +1,20 @@ +package org.noiseplanet.noisecapture.util + +/** + * Returns a string representing this string as a frequency value. + * The unit (Hz or kHz) will be dynamically inferred from the int value. + * + * @return Frequency representation of self. + */ +fun Int.toFrequencyString(): String { + return if (this >= 1000) { + if (this % 1000 > 0) { + val subKilo = (this % 1000).toString().trimEnd('0') + "${this / 1000}.$subKilo kHz" + } else { + "${this / 1000} kHz" + } + } else { + "$this Hz" + } +} diff --git a/composeApp/src/commonTest/kotlin/org/noiseplanet/noisecapture/signal/TestFFT.kt b/composeApp/src/commonTest/kotlin/org/noiseplanet/noisecapture/signal/TestFFT.kt index f5f15fa..ebcdbda 100644 --- a/composeApp/src/commonTest/kotlin/org/noiseplanet/noisecapture/signal/TestFFT.kt +++ b/composeApp/src/commonTest/kotlin/org/noiseplanet/noisecapture/signal/TestFFT.kt @@ -1,16 +1,16 @@ package org.noiseplanet.noisecapture.signal import kotlinx.coroutines.test.runTest -import org.noiseplanet.noisecapture.audio.signal.Bluestein import org.noiseplanet.noisecapture.audio.signal.SpectrumChannel -import org.noiseplanet.noisecapture.audio.signal.fft -import org.noiseplanet.noisecapture.audio.signal.fftFloat +import org.noiseplanet.noisecapture.audio.signal.bluestein.Bluestein +import org.noiseplanet.noisecapture.audio.signal.fft.fft +import org.noiseplanet.noisecapture.audio.signal.fft.fftFloat +import org.noiseplanet.noisecapture.audio.signal.fft.nextPowerOfTwo +import org.noiseplanet.noisecapture.audio.signal.fft.realFFT +import org.noiseplanet.noisecapture.audio.signal.fft.realFFTFloat +import org.noiseplanet.noisecapture.audio.signal.fft.realIFFT +import org.noiseplanet.noisecapture.audio.signal.fft.realIFFTFloat import org.noiseplanet.noisecapture.audio.signal.get48000HZ -import org.noiseplanet.noisecapture.audio.signal.nextPowerOfTwo -import org.noiseplanet.noisecapture.audio.signal.realFFT -import org.noiseplanet.noisecapture.audio.signal.realFFTFloat -import org.noiseplanet.noisecapture.audio.signal.realIFFT -import org.noiseplanet.noisecapture.audio.signal.realIFFTFloat import kotlin.math.PI import kotlin.math.ceil import kotlin.math.cos diff --git a/composeApp/src/commonTest/kotlin/org/noiseplanet/noisecapture/signal/TestWindowAnalysis.kt b/composeApp/src/commonTest/kotlin/org/noiseplanet/noisecapture/signal/TestWindowAnalysis.kt index ead729c..d31c1c5 100644 --- a/composeApp/src/commonTest/kotlin/org/noiseplanet/noisecapture/signal/TestWindowAnalysis.kt +++ b/composeApp/src/commonTest/kotlin/org/noiseplanet/noisecapture/signal/TestWindowAnalysis.kt @@ -5,11 +5,17 @@ import org.noiseplanet.noisecapture.audio.AcousticIndicatorsProcessing import org.noiseplanet.noisecapture.audio.AudioSamples import org.noiseplanet.noisecapture.audio.WINDOW_TIME import org.noiseplanet.noisecapture.audio.signal.FAST_DECAY_RATE +import org.noiseplanet.noisecapture.audio.signal.FrequencyBand +import org.noiseplanet.noisecapture.audio.signal.FrequencyBand.Companion.emptyFrequencyBands import org.noiseplanet.noisecapture.audio.signal.LevelDisplayWeightedDecay -import org.noiseplanet.noisecapture.audio.signal.SpectrumData -import org.noiseplanet.noisecapture.audio.signal.Window -import org.noiseplanet.noisecapture.audio.signal.WindowAnalysis +import org.noiseplanet.noisecapture.audio.signal.window.SpectrumData +import org.noiseplanet.noisecapture.audio.signal.window.SpectrumDataProcessing +import org.noiseplanet.noisecapture.audio.signal.window.Window import kotlin.math.PI +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.max import kotlin.math.min import kotlin.math.pow import kotlin.math.sin @@ -25,7 +31,7 @@ class TestWindowAnalysis { 0f, 0.0954915f, 0.3454915f, 0.6545085f, 0.9045085f, 1f, 0.9045085f, 0.6545085f, 0.3454915f, 0.0954915f, 0f ) - val windowAnalysis = WindowAnalysis(44100, expected.size, 1) + val windowAnalysis = SpectrumDataProcessing(44100, expected.size, 1) expected.forEachIndexed { index, value -> assertEquals(value, windowAnalysis.hannWindow?.get(index) ?: 0.0F, 1e-8f) @@ -36,7 +42,7 @@ class TestWindowAnalysis { fun testOverlapWindows() { val arraySize = 13 val ones = FloatArray(arraySize) { if (it in 2..arraySize - 3) 1f else 0f } - val windowAnalysis = WindowAnalysis(1, 5, 2) + val windowAnalysis = SpectrumDataProcessing(1, 5, 2) val processedWindows = ArrayList() windowAnalysis.pushSamples(0, ones, processedWindows).toList() assertEquals(5, processedWindows.size) @@ -47,7 +53,7 @@ class TestWindowAnalysis { fun testOverlapWindowsSegments() { for (arraySize in 9..13) { val ones = FloatArray(arraySize) { if (it in 2..arraySize - 3) 1f else 0f } - val windowAnalysis = WindowAnalysis(1, 5, 2) + val windowAnalysis = SpectrumDataProcessing(1, 5, 2) val processedWindows = ArrayList() windowAnalysis.pushSamples( (arraySize * 0.6).toLong(), @@ -78,7 +84,7 @@ class TestWindowAnalysis { val arraySize = 13 val ones = FloatArray(arraySize) { if (it in 2..arraySize - 3) 1f else 0f } // val ones = FloatArray(arraySize) {it.toFloat()} - val windowAnalysis = WindowAnalysis(1, 5, 2) + val windowAnalysis = SpectrumDataProcessing(1, 5, 2) val processedWindows = ArrayList() val step = 3 for (i in ones.indices step step) { @@ -121,7 +127,7 @@ class TestWindowAnalysis { val bufferSize = (sampleRate * 0.1).toInt() var cursor = 0 - val wa = WindowAnalysis(sampleRate, 4096, 4096, applyHannWindow = false) + val wa = SpectrumDataProcessing(sampleRate, 4096, 4096, applyHannWindow = false) val spectrumDataArray = ArrayList() while (cursor < signal.size) { val windowSize = min(bufferSize, signal.size - cursor) @@ -162,7 +168,7 @@ class TestWindowAnalysis { val bufferSize = (sampleRate * 0.1).toInt() var cursor = 0 val windowSize = (sampleRate * 0.125).toInt() - val wa = WindowAnalysis(sampleRate, windowSize, windowSize / 2) + val wa = SpectrumDataProcessing(sampleRate, windowSize, windowSize / 2) val spectrumDataArray = ArrayList() while (cursor < signal.size) { val windowSize = min(bufferSize, signal.size - cursor) @@ -175,12 +181,12 @@ class TestWindowAnalysis { val thirdOctaveSquare = spectrumData.thirdOctaveProcessing( 50.0, 12000.0, - octaveWindow = SpectrumData.OctaveWindow.RECTANGULAR + octaveWindow = OctaveWindow.RECTANGULAR ).asList() val thirdOctaveFractional = spectrumData.thirdOctaveProcessing( 50.0, 12000.0, - octaveWindow = SpectrumData.OctaveWindow.FRACTIONAL + octaveWindow = OctaveWindow.FRACTIONAL ).asList() val indexOf1000Hz = thirdOctaveSquare.indexOfFirst { t -> t.midFrequency.toInt() == 1000 } @@ -238,3 +244,63 @@ class TestWindowAnalysis { assertEquals(expectedLevel, averageLeq, 0.01) } } + +enum class OctaveWindow { + RECTANGULAR, + FRACTIONAL +} + +/** + * @see ref + * Class 0 filter is 0.15 dB error according to IEC 61260 + * + * @param firstFrequencyBand Skip bands up to specified frequency + * @param lastFrequencyBand Skip bands higher than this frequency + * @param base Octave base 10 or base 2 + * @param octaveWindow Rectangular association of frequency band or fractional close to done by a filter + */ +@Suppress("NestedBlockDepth") +fun SpectrumData.thirdOctaveProcessing( + firstFrequencyBand: Double, + lastFrequencyBand: Double, + base: FrequencyBand.BaseMethod = FrequencyBand.BaseMethod.B10, + bandDivision: Double = 3.0, + octaveWindow: OctaveWindow = OctaveWindow.FRACTIONAL, +): Array { + val freqByCell: Double = (spectrum.size.toDouble() * 2) / sampleRate + val thirdOctave = emptyFrequencyBands( + firstFrequencyBand, lastFrequencyBand, base, bandDivision, + ) + + if (octaveWindow == OctaveWindow.FRACTIONAL) { + for (band in thirdOctave) { + for (cellIndex in spectrum.indices) { + val f = (cellIndex + 1) / freqByCell + val division = + (f / band.midFrequency - band.midFrequency / f) * 1.507 * bandDivision + val cellGain = sqrt(1.0 / (1.0 + division.pow(6))) + val fg = 10.0.pow(spectrum[cellIndex] / 10.0) * cellGain + if (fg.isFinite()) { + band.spl += fg + } + } + } + for (band in thirdOctave) { + band.spl = 10 * log10(band.spl) + } + } else { + for (band in thirdOctave) { + val minCell = max(0, floor(band.minFrequency * freqByCell).toInt()) + val maxCell = min(spectrum.size, ceil(band.maxFrequency * freqByCell).toInt()) + var rms = 0.0 + for (cellIndex in minCell.. { JsAudioSource() } + factory { + JsAudioSource(logger = get()) + } } diff --git a/composeApp/src/wasmJsMain/kotlin/org/noiseplanet/noisecapture/audio/JsAudioSource.kt b/composeApp/src/wasmJsMain/kotlin/org/noiseplanet/noisecapture/audio/JsAudioSource.kt index 0872b2f..1a5e2b0 100644 --- a/composeApp/src/wasmJsMain/kotlin/org/noiseplanet/noisecapture/audio/JsAudioSource.kt +++ b/composeApp/src/wasmJsMain/kotlin/org/noiseplanet/noisecapture/audio/JsAudioSource.kt @@ -4,85 +4,103 @@ import kotlinx.browser.window import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.datetime.Clock import org.khronos.webgl.get import org.noiseplanet.noisecapture.interop.AudioContext import org.noiseplanet.noisecapture.interop.AudioNode -import org.w3c.dom.mediacapture.MediaStream +import org.noiseplanet.noisecapture.interop.ScriptProcessorNode +import org.noiseplanet.noisecapture.log.Logger import org.w3c.dom.mediacapture.MediaStreamConstraints const val SAMPLES_BUFFER_SIZE = 1024 -const val AUDIO_CONSTRAINT = - "{audio: {echoCancellation: false, autoGainControl: false, noiseSuppression: false}}" /** * TODO: Document, cleanup, use platform logger instead of println, get rid of force unwraps (!!) */ -internal class JsAudioSource : AudioSource { +internal class JsAudioSource( + private val logger: Logger, +) : AudioSource { private var audioContext: AudioContext? = null - private var mediaStream: MediaStream? = null private var micNode: AudioNode? = null - private var scriptProcessorNode: AudioNode? = null + private var scriptProcessorNode: ScriptProcessorNode? = null private val audioSamplesChannel = Channel( onBufferOverflow = BufferOverflow.DROP_OLDEST ) override suspend fun setup(): Flow { - println("Launch JSAudioSource") + logger.debug("Launch JSAudioSource...") + window.navigator.mediaDevices.getUserMedia( MediaStreamConstraints( + // TODO: Not sure this has any effect... audio = object { - val audioCancellation = false + val echoCancellation = false + val autoGainControl = false + val noiseSuppression = false }.toJsReference() ) ).then(onFulfilled = { mediaStream -> - println("Got it") - - this.mediaStream = mediaStream audioContext = AudioContext() - println("AudioContext ready $audioContext.") - micNode = audioContext!!.createMediaStreamSource(mediaStream) - val scriptProcessorNode = - audioContext!!.createScriptProcessor(SAMPLES_BUFFER_SIZE, 1, 1) - scriptProcessorNode.onaudioprocess = { audioProcessingEvent -> + logger.debug("AudioContext ready $audioContext.") + + micNode = audioContext?.createMediaStreamSource(mediaStream) + checkNotNull(micNode) { "Failed initializing mic node" } + + scriptProcessorNode = audioContext?.createScriptProcessor( + bufferSize = SAMPLES_BUFFER_SIZE, + numberOfInputChannels = 1, + numberOfOutputChannels = 1 + ) + checkNotNull(scriptProcessorNode) { "Failed initializing script processor node" } + + scriptProcessorNode?.onaudioprocess = { audioProcessingEvent -> + val timestamp = Clock.System.now().toEpochMilliseconds() + logger.debug("New samples: ${audioProcessingEvent.inputBuffer}") + val buffer = audioProcessingEvent.inputBuffer val jsBuffer = buffer.getChannelData(0) val samplesBuffer = FloatArray(jsBuffer.length) { i -> jsBuffer[i] } + audioSamplesChannel.trySend( AudioSamples( - Clock.System.now().toEpochMilliseconds(), + timestamp, samplesBuffer, buffer.sampleRate.toInt() ) ) } - micNode!!.connect(scriptProcessorNode) - scriptProcessorNode.connect(audioContext!!.destination) - micNode!!.connect(scriptProcessorNode) + scriptProcessorNode?.let { scriptProcessorNode -> + micNode?.connect(scriptProcessorNode) + audioContext?.let { audioContext -> + scriptProcessorNode.connect(audioContext.destination) + } + } mediaStream }, onRejected = { error -> - println("Error! $error") + logger.error("Error while setting up audio source: $error") error }) - return audioSamplesChannel.consumeAsFlow() + return audioSamplesChannel.receiveAsFlow() } override fun release() { + logger.debug("Releasing audio source") + micNode?.disconnect() scriptProcessorNode?.disconnect() try { audioContext?.close()?.catch { error -> // ignore - println(error) + logger.error("Error while closing audio context: $error") error } } catch (ignore: Exception) { // Ignore - println(ignore.stackTraceToString()) + logger.error("Uncaught exception:", ignore) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3d3a9aa..cf2db28 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ androidx-appcompat = "1.7.0" androidx-constraintlayout = "2.1.4" androidx-core-ktx = "1.13.1" androidx-espresso-core = "3.6.0" -androidx-lifecycle-viewmodel = "2.8.3" +androidx-viewmodel-compose = "2.8.0" androidx-material = "1.12.0" androidx-navigation = "2.7.0-alpha07" androidx-test-junit = "1.2.0" @@ -30,13 +30,14 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" } -androidx-lifecycle-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-viewmodel" } +androidx-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-viewmodel-compose" } androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" } androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } junit = { group = "junit", name = "junit", version.ref = "junit" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin-compose-multiplatform" } +koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin-compose-multiplatform" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } kotlin-browser = { module = "org.jetbrains.kotlin-wrappers:kotlin-browser", version.ref = "kotlin-wrappers" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }