Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Spectrogram legend #7

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/static.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:
with:
key: noisecapturejs
path: |
webApp/build
$GITHUB_WORKSPACE/webApp/build
- name: Build
run: >
./gradlew
Expand Down
34 changes: 5 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,12 @@
# About NoiseCapture App
# About NoiseCapture

![Build status](https://github.com/nicolas-f/NoiseCaptureKotlin/actions/workflows/test.yml/badge.svg)
![Build status](https://github.com/nicolas-f/NoiseCaptureKotlin/actions/workflows/code_review.yml/badge.svg)

**NoiseCapture App** is Android App dedicated to the measurement of environmental noise.
**NoiseCapture** is an Android/Ios/Web App dedicated to the measurement of environmental noise.

## Description
**NoiseCapture App** is an Android App project for measuring environmental noise using a smartphone. The goal is to **produce relevant noise indicators from audio measurements, including a geospatial representation**. Measurements can be shared with the community in order to produce participatory noise maps. **NoiseCapture App** is a component of a global infrastructure, _i.e._ a Spatial Data Infrastructure (SDI), called the **OnoMap SDI**, that allows to process and represent the geospatial information, like noise maps.

* A [**full description**](https://github.com/Ifsttar/NoiseCapture/wiki) of the whole OnoMap SDI, including the NoiseCapture App, is given in the [wiki pages](https://github.com/Ifsttar/NoiseCapture/wiki).
* An **user guide**, for the use of the NoiseCapture App, is proposed within the NoiseCapture App (see the 'Help' page in the menu of NoiseCapture App).

## Features

NoiseCapture App features are divided into 3 parts:

- Measurement - Once the sound level calibration is done, the user start the measurement in order to record each second the LAeq, an average sound energy over a period of 1s. The spectrum repartition of the sound are analysed and stored using the Fourrier transform. The device location are recorded while measuring the sound level. The user has the hability to provide his own feedback about the feeling of the noise environment.

- Extented report - Advanced statistics are computed locally on the phone and shown to the user. For each user's measurement the locations of the noise levels are displayed in a map.

- Share results with the community - Anonymous results are transfered to Virtual Hubs (web server) and post-processed in order to build a noise map that merge all community results. Participative noise maps can be displayed within the NoiseCapture App, or online at https://onomap.noise-planet.org/.

## Developments
NoiseCapture App is a collaboration between the [Environmental Acoustic Research unit](http://www.umrae.fr/en/) ([Ifsttar](http://www.ifsttar.fr)) and the [Lab-STICC](http://www.lab-sticc.fr/) CNRS. If you need more information about the project developped by the Environmental Acoustic Research unit and the Lab-STICC, on this topic, go to [http://www.noise-planet.org](http://noise-planet.org).

## Download

[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/org.noise_planet.noisecapture/)
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
alt="Get it on Google Play"
height="80">](https://play.google.com/store/apps/details?id=org.noise_planet.noisecapture)
**NoiseCapture** is an Android/Ios/Web App for measuring environmental noise using a smartphone/tablet device.
The goal is to **produce relevant noise indicators from audio measurements, including a geospatial representation**. Measurements can be shared with the community in order to produce participatory noise maps. **NoiseCapture App** is a component of a global infrastructure, _i.e._ a Spatial Data Infrastructure (SDI), called the **OnoMap SDI**, that allows to process and represent the geospatial information, like noise maps.

## Funding
This application was developed under the initial funding the European project [ENERGIC-OD](http://www.energic-od.eu/), with the help of the [GEOPAL](http://www.geopal.org/accueil) program.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,40 @@ package org.noise_planet.noisecapture.shared.child

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.width
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
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.IntSize
import androidx.compose.ui.unit.dp
import com.bumble.appyx.components.backstack.BackStack
import com.bumble.appyx.navigation.lifecycle.DefaultPlatformLifecycleObserver
import com.bumble.appyx.navigation.lifecycle.Lifecycle
import com.bumble.appyx.navigation.modality.BuildContext
import com.bumble.appyx.navigation.node.Node
import kotlinx.coroutines.launch
Expand All @@ -27,7 +45,13 @@ import org.noise_planet.noisecapture.shared.MeasurementService
import org.noise_planet.noisecapture.shared.ScreenData
import org.noise_planet.noisecapture.shared.ui.SpectrogramBitmap
import org.noise_planet.noisecapture.toImageBitmap
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.log
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.round

const val FFT_SIZE = 4096
Expand All @@ -43,33 +67,140 @@ class MeasurementScreen(buildContext: BuildContext, val backStack: BackStack<Scr

@Composable
fun spectrogram(spectrumCanvasState : SpectrogramViewModel) {
Canvas(modifier = Modifier.fillMaxSize()) {
val canvasSize = IntSize(SPECTROGRAM_STRIP_WIDTH, size.height.toInt())
spectrumCanvasState.spectrogramCanvasSize = size
val textMeasurer = rememberTextMeasurer()
val frequencyLegendPosition = when (spectrumCanvasState.currentStripData.scaleMode) {
SpectrogramBitmap.Companion.SCALE_MODE.SCALE_LOG -> SpectrogramBitmap.frequencyLegendPositionLog
else -> SpectrogramBitmap.frequencyLegendPositionLinear
}
val timeXLabelMeasure = textMeasurer.measure(" +99s ")
val timeXLabelHeight = timeXLabelMeasure.size.height
val maxYLabelWidth =
frequencyLegendPosition.maxOf { frequency ->
val text = formatFrequency(frequency)
textMeasurer.measure(text).size.width
}
Canvas(modifier = Modifier.fillMaxSize() ) {
drawRect(color = Color.Black, size=size)
val tickLength = 4.dp.toPx()
val tickStroke = 2.dp
val legendHeight = timeXLabelHeight+tickLength
val canvasSize = IntSize(SPECTROGRAM_STRIP_WIDTH, (size.height - legendHeight).toInt())
val legendWidth = maxYLabelWidth+tickLength
spectrumCanvasState.spectrogramCanvasSize = Size(size.width - legendWidth, size.height
- legendHeight)
if(spectrumCanvasState.currentStripData.size != canvasSize) {
// reset buffer on resize or first draw
spectrumCanvasState.currentStripData = SpectrogramBitmap.createSpectrogram(canvasSize)
spectrumCanvasState.currentStripData = SpectrogramBitmap.createSpectrogram(
canvasSize, SpectrogramBitmap.Companion.SCALE_MODE.SCALE_LOG, spectrumCanvasState.currentStripData.sampleRate)
spectrumCanvasState.cachedStrips.clear()
} else {
drawImage(spectrumCanvasState.currentStripData.byteArray.toImageBitmap(),
topLeft = Offset(size.width - spectrumCanvasState.currentStripData.offset, 0F))
spectrumCanvasState.cachedStrips.reversed().forEachIndexed { index, imageBitmap ->
val bitmapX = size.width - ((index + 1) * SPECTROGRAM_STRIP_WIDTH
+ spectrumCanvasState.currentStripData.offset).toFloat()
drawImage(imageBitmap,
topLeft = Offset(bitmapX, 0F))
if(spectrumCanvasState.currentStripData.sampleRate > 1) {
drawImage(
spectrumCanvasState.currentStripData.byteArray.toImageBitmap(),
topLeft = Offset(
size.width - spectrumCanvasState.currentStripData.offset - legendWidth,
0F
)
)
spectrumCanvasState.cachedStrips.reversed()
.forEachIndexed { index, imageBitmap ->
val bitmapX = size.width - legendWidth - ((index + 1) * SPECTROGRAM_STRIP_WIDTH
+ spectrumCanvasState.currentStripData.offset).toFloat()
drawImage(
imageBitmap,
topLeft = Offset(bitmapX, 0F)
)
}
// black background of legend
drawRect(color = Color.Black, size = Size(legendWidth, size.height),
topLeft = Offset(size.width - legendWidth, 0F))
// draw Y axe labels
val fMax = spectrumCanvasState.currentStripData.sampleRate / 2
val fMin = frequencyLegendPosition[0].toDouble()
val r = fMax / fMin
val sheight = spectrumCanvasState.currentStripData.size.height
frequencyLegendPosition.forEachIndexed { index, frequency ->
val text = buildAnnotatedString {
withStyle(style = SpanStyle(color = Color.White)) {
append(formatFrequency(frequency))
}
}
val textSize = textMeasurer.measure(text)
val tickHeightPos = when (spectrumCanvasState.currentStripData.scaleMode) {
SpectrogramBitmap.Companion.SCALE_MODE.SCALE_LOG -> {
sheight - (log10(frequency / fMin) / ((log10(r) / sheight))).toInt()
}

else -> 0
}
drawLine(
color = Color.White, start = Offset(
size.width - legendWidth,
tickHeightPos.toFloat() - tickStroke.toPx()/2
),
end = Offset(
size.width - legendWidth + tickLength,
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, textPos.toFloat()))
}
// draw X axe labels
val xLegendWidth = (size.width - legendWidth)
val maxLabelsOnXAxe = ceil(xLegendWidth / timeXLabelMeasure.size.width).toInt()
// One pixel per time step
val timePerPixel = FFT_HOP / spectrumCanvasState.currentStripData.sampleRate
val timeBetweenLabels = floor((xLegendWidth * timePerPixel) / maxLabelsOnXAxe)
// start with 1 second then increase values
(1.. maxLabelsOnXAxe).forEach { labelIndex ->
val timeValue = (1 + timeBetweenLabels * (labelIndex - 1)).toInt()
val xPos = (xLegendWidth - timeValue / timePerPixel).toFloat()
drawLine(
color = Color.White, start = Offset(
xPos-tickStroke.toPx()/2,
sheight.toFloat()
),
end = Offset(
xPos-tickStroke.toPx()/2,
sheight.toFloat() + tickLength
),
strokeWidth = tickStroke.toPx()
)
val legendText = buildAnnotatedString {
withStyle(style = SpanStyle(color = Color.White)) {
append("+${timeValue}s")
}
}
drawText(textMeasurer,legendText, topLeft = Offset(xPos-textMeasurer.measure(legendText).size.width / 2, sheight.toFloat() + tickLength))
}
}
}
}
}


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"
}
}

@Composable
override fun View(modifier: Modifier) {
var noiseLevel by remember { mutableStateOf(0.0) }
var spectrumCanvasState by remember { mutableStateOf(
SpectrogramViewModel(SpectrogramBitmap.SpectrogramDataModel(IntSize(1, 1),
ByteArray(Int.SIZE_BYTES)), ArrayList(), Size.Zero)) }
ByteArray(Int.SIZE_BYTES),0 ,SpectrogramBitmap.Companion.SCALE_MODE.SCALE_LOG, 1.0), ArrayList(), Size.Zero)) }

lifecycleScope.launch {
println("Launch lifecycle")
Expand All @@ -93,20 +224,24 @@ class MeasurementScreen(buildContext: BuildContext, val backStack: BackStack<Scr
// spectrogram band complete, store bitmap
spectrumCanvasState.cachedStrips.add(
spectrumCanvasState.currentStripData.byteArray.toImageBitmap())
if((spectrumCanvasState.cachedStrips.size - 1) * SPECTROGRAM_STRIP_WIDTH > spectrumCanvasState.spectrogramCanvasSize.width) {
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 =
SpectrogramBitmap.createSpectrogram(
spectrumCanvasState.currentStripData.size,
spectrumCanvasState.currentStripData.scaleMode,
measurementService!!.sampleRate.toDouble())
bitmapChanged = false
continue
}
spectrumCanvasState.currentStripData.pushSpectrumToSpectrogramData(
measurementServiceData.spectrumDataList.subList(indexToProcess,
indexToProcess + subListSizeToCompleteStrip),
SpectrogramBitmap.Companion.SCALE_MODE.SCALE_LOG,
mindB, rangedB, measurementService!!.sampleRate.toDouble()
)
mindB, rangedB)
bitmapChanged = true
indexToProcess += subListSizeToCompleteStrip
}
Expand All @@ -123,7 +258,6 @@ class MeasurementScreen(buildContext: BuildContext, val backStack: BackStack<Scr
println("Release audio")
audioSource.release()
}

Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ class SpectrogramBitmap {
"#F75500".toComposeColor(),
"#FB2A00".toComposeColor(),
)
fun createSpectrogram(size: IntSize) : SpectrogramDataModel {
fun createSpectrogram(size: IntSize, scaleMode: SCALE_MODE, sampleRate: Double) : SpectrogramDataModel {
val byteArray = ByteArray(bmpHeader.size + Int.SIZE_BYTES * size.width * size.height)
bmpHeader.copyInto(byteArray)
// fill with changing header data
Expand All @@ -111,7 +111,7 @@ class SpectrogramBitmap {
(rawPixelSize+bmpHeader.size).toLittleEndianBytes().copyInto(byteArray, sizeIndex)
size.width.toLittleEndianBytes().copyInto(byteArray, widthIndex)
size.height.toLittleEndianBytes().copyInto(byteArray, heightIndex)
return SpectrogramDataModel(size, byteArray)
return SpectrogramDataModel(size, byteArray, scaleMode = scaleMode, sampleRate = sampleRate)
}

/**
Expand All @@ -126,7 +126,9 @@ class SpectrogramBitmap {
* @constructor
* @si
*/
data class SpectrogramDataModel(val size: IntSize, val byteArray: ByteArray, var offset : Int = 0) {
data class SpectrogramDataModel(val size: IntSize, val byteArray: ByteArray,
var offset : Int = 0, val scaleMode: SCALE_MODE,
val sampleRate: Double) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
Expand All @@ -144,9 +146,7 @@ class SpectrogramBitmap {
}

fun pushSpectrumToSpectrogramData(fftResults : List<SpectrumData>,
scaleMode: SCALE_MODE,
mindB : Double, rangedB : Double,
sampleRate: Double) {
mindB : Double, rangedB : Double) {
// generate columns of pixels
// merge power of each frequencies following the destination bitmap resolution
val hertzBySpectrumCell = sampleRate / FFT_SIZE.toDouble()
Expand Down
Loading