initial commit

This commit is contained in:
sergeych committed 2025-10-13 11:23:20 +04:00
commit e068ad52de
42 files changed
+2223

No files matched your search

@@ -0,0 +1,24 @@
package net.sergeych.karabass
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("net.sergeych.karabass", appContext.packageName)
}
}
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Karabass">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Karabass">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,399 @@
package net.sergeych.karabass
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Build
import kotlin.math.*
class AdvancedTubaSynth(private val context: Context) {
companion object {
const val SAMPLE_RATE = 44100
const val CUTOFF_FREQ = 800.0
// Полный звуковой ряд тубы с полутонами (от C1 до B5)
val TUBA_NOTES = listOf(
// Октава 1
"C1" to 32.70f, "C#1" to 34.65f, "D1" to 36.71f, "D#1" to 38.89f, "E1" to 41.20f, "F1" to 43.65f,
"F#1" to 46.25f, "G1" to 49.00f, "G#1" to 51.91f, "A1" to 55.00f, "A#1" to 58.27f, "B1" to 61.74f,
// Октава 2
"C2" to 65.41f, "C#2" to 69.30f, "D2" to 73.42f, "D#2" to 77.78f, "E2" to 82.41f, "F2" to 87.31f,
"F#2" to 92.50f, "G2" to 98.00f, "G#2" to 103.83f, "A2" to 110.00f, "A#2" to 116.54f, "B2" to 123.47f,
// Октава 3
"C3" to 130.81f, "C#3" to 138.59f, "D3" to 146.83f, "D#3" to 155.56f, "E3" to 164.81f, "F3" to 174.61f,
"F#3" to 185.00f, "G3" to 196.00f, "G#3" to 207.65f, "A3" to 220.00f, "A#3" to 233.08f, "B3" to 246.94f,
// Октава 4
"C4" to 261.63f, "C#4" to 277.18f, "D4" to 293.66f, "D#4" to 311.13f, "E4" to 329.63f, "F4" to 349.23f,
"F#4" to 369.99f, "G4" to 392.00f, "G#4" to 415.30f, "A4" to 440.00f, "A#4" to 466.16f, "B4" to 493.88f,
// Октава 5 (верхний предел для продвинутых исполнителей)
"C5" to 523.25f, "C#5" to 554.37f, "D5" to 587.33f, "D#5" to 622.25f, "E5" to 659.25f, "F5" to 698.46f,
"F#5" to 739.99f, "G5" to 783.99f, "G#5" to 830.61f, "A5" to 880.00f, "A#5" to 932.33f, "B5" to 987.77f
)
}
@Volatile private var audioTrack: AudioTrack? = null
@Volatile private var isPlaying = false
@Volatile private var shouldStop = false
private var currentThread: Thread? = null
// Синхронизированные переменные для потокобезопасности
private val lock = Object()
private var currentFrequency = 0f
private var targetFrequency = 0f
private var isNoteOn = false
private var currentAmplitude = 0.0
private var samplesSinceNoteOn = 0
private var samplesSinceNoteOff = 0
private var phase = 0.0
// Явное состояние огибающей
private enum class EnvelopeState { IDLE, ATTACK, DECAY, SUSTAIN, RELEASE }
private var envelopeState = EnvelopeState.IDLE
// Оптимизированные параметры для уменьшения задержки
private val attackTime = (0.005 * SAMPLE_RATE).toInt() // 5ms атака
private val decayTime = (0.03 * SAMPLE_RATE).toInt() // 30ms спад
private val releaseTime = (0.05 * SAMPLE_RATE).toInt() // 50ms релиз
private val sustainLevel = 0.8
fun startNote(frequency: Float) {
synchronized(lock) {
targetFrequency = frequency
if (!isNoteOn) {
// Новая нота - сбрасываем все счетчики
isNoteOn = true
samplesSinceNoteOn = 0
samplesSinceNoteOff = 0
envelopeState = EnvelopeState.ATTACK
currentFrequency = frequency
if (!isPlaying) {
startAudioGeneration()
}
} else {
// Легато - быстрый переход
targetFrequency = frequency
}
}
}
fun stopNote() {
synchronized(lock) {
if (isNoteOn) {
isNoteOn = false
samplesSinceNoteOff = 0
// Переходим в состояние релиза только если мы не в IDLE
if (envelopeState != EnvelopeState.IDLE) {
envelopeState = EnvelopeState.RELEASE
}
}
}
}
fun changeNote(frequency: Float) {
synchronized(lock) {
targetFrequency = frequency
}
}
private fun startAudioGeneration() {
shouldStop = false
isPlaying = true
// Минимальный буфер для low-latency
val bufferSize = 512 // ~11ms latency
try {
audioTrack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val attributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
val format = AudioFormat.Builder()
.setSampleRate(SAMPLE_RATE)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build()
AudioTrack.Builder()
.setAudioAttributes(attributes)
.setAudioFormat(format)
.setBufferSizeInBytes(bufferSize * 2)
.build()
} else {
@Suppress("DEPRECATION")
AudioTrack(
AudioManager.STREAM_MUSIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize * 2,
AudioTrack.MODE_STREAM
)
}
audioTrack?.play()
} catch (e: Exception) {
e.printStackTrace()
isPlaying = false
return
}
currentThread = Thread {
val buffer = ShortArray(bufferSize)
try {
while (!shouldStop && isPlaying) {
// Заполняем буфер
for (i in buffer.indices) {
val sample = generateSample()
buffer[i] = (sample.coerceIn(-1.0, 1.0) * Short.MAX_VALUE).toInt().toShort()
}
// Безопасная запись в AudioTrack
val track = audioTrack
if (track?.playState == AudioTrack.PLAYSTATE_PLAYING) {
try {
track.write(buffer, 0, buffer.size)
} catch (e: Exception) {
// Игнорируем ошибки записи при остановке
if (!shouldStop) {
e.printStackTrace()
}
break
}
} else {
break
}
// Проверяем, нужно ли остановить генерацию
synchronized(lock) {
if (envelopeState == EnvelopeState.IDLE && !isNoteOn) {
isPlaying = false
}
}
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
// Безопасная остановка
try {
audioTrack?.stop()
} catch (e: Exception) {
e.printStackTrace()
}
isPlaying = false
}
}.apply {
start()
}
}
private fun generateSample(): Double {
synchronized(lock) {
// Быстрое изменение частоты для легато
if (abs(currentFrequency - targetFrequency) > 0.1) {
currentFrequency += (targetFrequency - currentFrequency) * 0.3f
}
// Расчет огибающей
val envelope = calculateEnvelope()
// Обновление счетчиков в зависимости от состояния
when (envelopeState) {
EnvelopeState.ATTACK, EnvelopeState.DECAY, EnvelopeState.SUSTAIN -> {
samplesSinceNoteOn++
}
EnvelopeState.RELEASE -> {
samplesSinceNoteOff++
}
EnvelopeState.IDLE -> {
// Ничего не делаем
}
}
// Проверяем переходы между состояниями
checkStateTransitions()
// Если звук полностью затух, возвращаем 0
if (envelopeState == EnvelopeState.IDLE) {
return 0.0
}
// Генерация waveform с сохранением фазы для избежания щелчков
val wave = calculateTubaWaveform(currentFrequency)
return wave * envelope
}
}
private fun calculateEnvelope(): Double {
return when (envelopeState) {
EnvelopeState.ATTACK -> {
if (samplesSinceNoteOn < attackTime) {
// Быстрая атака
(samplesSinceNoteOn / attackTime.toDouble()).pow(0.5)
} else {
// Переход к DECAY будет обработан в checkStateTransitions
(attackTime / attackTime.toDouble()).pow(0.5)
}
}
EnvelopeState.DECAY -> {
val decayProgress = (samplesSinceNoteOn - attackTime) / decayTime.toDouble()
if (decayProgress < 1.0) {
// Плавный спад до сустейна
1.0 - (1.0 - sustainLevel) * decayProgress
} else {
// Переход к SUSTAIN будет обработан в checkStateTransitions
sustainLevel
}
}
EnvelopeState.SUSTAIN -> {
sustainLevel
}
EnvelopeState.RELEASE -> {
val releaseProgress = samplesSinceNoteOff / releaseTime.toDouble()
if (releaseProgress < 1.0) {
// Экспоненциальный релиз
sustainLevel * exp(-6.0 * releaseProgress)
} else {
// Переход к IDLE будет обработан в checkStateTransitions
0.0
}
}
EnvelopeState.IDLE -> {
0.0
}
}.also { currentAmplitude = it }
}
private fun checkStateTransitions() {
when (envelopeState) {
EnvelopeState.ATTACK -> {
if (samplesSinceNoteOn >= attackTime) {
envelopeState = EnvelopeState.DECAY
}
}
EnvelopeState.DECAY -> {
if (samplesSinceNoteOn >= attackTime + decayTime) {
envelopeState = EnvelopeState.SUSTAIN
}
}
EnvelopeState.SUSTAIN -> {
// SUSTAIN продолжается пока isNoteOn = true
if (!isNoteOn) {
envelopeState = EnvelopeState.RELEASE
samplesSinceNoteOff = 0
}
}
EnvelopeState.RELEASE -> {
if (samplesSinceNoteOff >= releaseTime) {
envelopeState = EnvelopeState.IDLE
}
}
EnvelopeState.IDLE -> {
// Остаемся в IDLE
}
}
}
private fun calculateTubaWaveform(freq: Float): Double {
// Используем фазовый накопитель для избежания щелчков при смене нот
val phaseIncrement = 2.0 * PI * freq / SAMPLE_RATE
phase += phaseIncrement
// Сбрасываем фазу при переполнении для избежания потери точности
if (phase > 2.0 * PI) {
phase -= 2.0 * PI
}
val range = when {
freq < 50 -> "very_low"
freq < 100 -> "low"
freq < 200 -> "middle"
else -> "high"
}
val fundamental = sin(phase)
val overtones = when (range) {
"very_low" -> {
sin(2.0 * phase) * 0.6 +
sin(3.0 * phase) * 0.5 +
sin(4.0 * phase) * 0.4
}
"low" -> {
sin(2.0 * phase) * 0.5 +
sin(3.0 * phase) * 0.45 +
sin(4.0 * phase) * 0.4 +
sin(5.0 * phase) * 0.3
}
"middle" -> {
sin(2.0 * phase) * 0.4 +
sin(3.0 * phase) * 0.4 +
sin(4.0 * phase) * 0.35 +
sin(5.0 * phase) * 0.25 +
sin(6.0 * phase) * 0.2
}
else -> {
sin(2.0 * phase) * 0.3 +
sin(3.0 * phase) * 0.35 +
sin(4.0 * phase) * 0.3 +
sin(5.0 * phase) * 0.25 +
sin(6.0 * phase) * 0.2
}
}
val mixed = fundamental * 0.6 + overtones * 0.8
return tanh(mixed * 1.2) / 1.2
}
fun isActive(): Boolean {
synchronized(lock) {
return isNoteOn || envelopeState != EnvelopeState.IDLE
}
}
fun getCurrentFrequency(): Float {
synchronized(lock) {
return currentFrequency
}
}
fun release() {
shouldStop = true
isPlaying = false
// Безопасная остановка потока
currentThread?.let { thread ->
try {
thread.join(100) // Ждем завершения потока до 100ms
} catch (e: InterruptedException) {
thread.interrupt()
}
}
currentThread = null
// Безопасное освобождение AudioTrack
audioTrack?.let { track ->
try {
if (track.playState == AudioTrack.PLAYSTATE_PLAYING) {
track.stop()
}
track.release()
} catch (e: Exception) {
e.printStackTrace()
}
}
audioTrack = null
}
}
@@ -0,0 +1,65 @@
package net.sergeych.karabass
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import net.sergeych.karabass.ui.theme.KarabassTheme
class MainActivity : ComponentActivity() {
private lateinit var tubaSynth: AdvancedTubaSynth
private val tubaNotes = AdvancedTubaSynth.TUBA_NOTES
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
tubaSynth = AdvancedTubaSynth(this)
// Примеры нот для тубы (в Hz)
enableEdgeToEdge()
setContent {
KarabassTheme {
Scaffold(modifier =
Modifier.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
) { innerPadding ->
val lowNodeIndex = 1
val keysCount = 22
PianoKeyboard(
tubaSynth,
AdvancedTubaSynth.TUBA_NOTES.slice(lowNodeIndex .. lowNodeIndex + keysCount).map { it.first},
Modifier.padding(innerPadding).fillMaxSize()
)
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
KarabassTheme {
Greeting("Android")
}
}
@@ -0,0 +1,337 @@
package net.sergeych.karabass
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.platform.LocalConfiguration
@Composable
fun PianoKeyboard(
synth: AdvancedTubaSynth,
noteRange: List<String>,
modifier: Modifier = Modifier
) {
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp
val screenHeight = configuration.screenHeightDp
val isHorizontal = screenWidth > screenHeight
// Фильтруем и сортируем ноты по частоте
val availableNotes = AdvancedTubaSynth.TUBA_NOTES
.filter { it.first in noteRange }
.sortedBy { it.second }
// Разделяем на белые и черные клавиши
val whiteKeys = availableNotes.filter { !it.first.contains("#") }
val blackKeys = availableNotes.filter { it.first.contains("#") }
var activeKeys by remember { mutableStateOf(emptySet<String>()) }
Box(modifier = modifier.fillMaxSize()) {
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(synth, whiteKeys, blackKeys, isHorizontal, availableNotes) {
awaitPointerEventScope {
// Карта для отслеживания активных указателей и их нот
val activePointers = mutableMapOf<PointerId, String>()
while (true) {
val event = awaitPointerEvent()
// Обрабатываем все изменения в событии
for (change in event.changes) {
val pointerId = change.id
val position = change.position
val canvasSize = Size(size.width.toFloat(), size.height.toFloat())
// Находим клавишу под указателем
val key = findKeyAtOffset(
offset = position,
size = canvasSize,
whiteKeys = whiteKeys,
blackKeys = blackKeys,
isHorizontal = isHorizontal
)
when {
// НАЖАТИЕ - когда указатель только что нажат
change.pressed -> {
key?.let { (noteName, frequency) ->
// Запоминаем связь указатель-нота
activePointers[pointerId] = noteName
activeKeys = activeKeys + noteName
println("startNote $noteName $frequency")
if (synth.isActive()) {
// Легато - плавный переход на новую ноту
synth.changeNote(frequency)
} else {
// Новая нота
synth.startNote(frequency)
}
}
}
// ОТПУСКАНИЕ - когда указатель отпущен
!change.pressed -> {
val releasedNoteName = activePointers.remove(pointerId)
releasedNoteName?.let { noteName ->
activeKeys = activeKeys - noteName
if (activePointers.isEmpty()) {
// Все клавиши отпущены - останавливаем ноту
synth.stopNote()
} else {
// Есть другие активные клавиши - переключаемся на одну из них
val remainingNoteName = activePointers.values.firstOrNull()
remainingNoteName?.let { name ->
val remainingNote = availableNotes.find { it.first == name }
remainingNote?.let {
synth.changeNote(it.second)
}
}
}
}
}
// ПЕРЕМЕЩЕНИЕ - когда указатель переместился
change.positionChanged() -> {
key?.let { (newNoteName, newFrequency) ->
val currentNoteName = activePointers[pointerId]
// Если указатель переместился на другую клавишу
if (currentNoteName != newNoteName) {
// Обновляем активные клавиши
currentNoteName?.let {
activeKeys = activeKeys - it
}
activeKeys = activeKeys + newNoteName
activePointers[pointerId] = newNoteName
// Плавно переключаем ноту
if (synth.isActive()) {
synth.changeNote(newFrequency)
}
}
}
}
}
// Всегда сообщаем, что обработали событие
change.consume()
}
}
}
}
) {
if (isHorizontal) {
drawHorizontalKeyboard(
whiteKeys = whiteKeys,
blackKeys = blackKeys,
activeKeys = activeKeys,
size = size
)
} else {
drawVerticalKeyboard(
whiteKeys = whiteKeys,
blackKeys = blackKeys,
activeKeys = activeKeys,
size = size
)
}
}
}
}
// Вспомогательные функции остаются без изменений:
private fun findKeyAtOffset(
offset: Offset,
size: Size,
whiteKeys: List<Pair<String, Float>>,
blackKeys: List<Pair<String, Float>>,
isHorizontal: Boolean
): Pair<String, Float>? {
return if (isHorizontal) {
findKeyAtOffsetHorizontal(offset, size, whiteKeys, blackKeys)
} else {
findKeyAtOffsetVertical(offset, size, whiteKeys, blackKeys)
}
}
private fun findKeyAtOffsetHorizontal(
offset: Offset,
size: Size,
whiteKeys: List<Pair<String, Float>>,
blackKeys: List<Pair<String, Float>>
): Pair<String, Float>? {
val whiteKeyWidth = size.width / whiteKeys.size
// Сначала проверяем черные клавиши (они сверху и могут перекрывать белые)
blackKeys.forEachIndexed { index, blackKey ->
val blackKeyX = (getWhiteKeyIndexForBlackKey(blackKey.first, whiteKeys) * whiteKeyWidth) -
(whiteKeyWidth * 0.25f)
val blackKeyRect = Rect(
left = blackKeyX,
top = 0f,
right = blackKeyX + whiteKeyWidth * 0.5f,
bottom = size.height * 0.6f
)
if (blackKeyRect.contains(offset)) {
return blackKey
}
}
// Затем проверяем белые клавиши
whiteKeys.forEachIndexed { index, whiteKey ->
val whiteKeyRect = Rect(
left = index * whiteKeyWidth,
top = 0f,
right = (index + 1) * whiteKeyWidth,
bottom = size.height
)
if (whiteKeyRect.contains(offset)) {
return whiteKey
}
}
return null
}
private fun findKeyAtOffsetVertical(
offset: Offset,
size: Size,
whiteKeys: List<Pair<String, Float>>,
blackKeys: List<Pair<String, Float>>
): Pair<String, Float>? {
val whiteKeyHeight = size.height / whiteKeys.size
// Сначала проверяем черные клавиши (они справа и могут перекрывать белые)
blackKeys.forEachIndexed { index, blackKey ->
val blackKeyY = (getWhiteKeyIndexForBlackKey(blackKey.first, whiteKeys) * whiteKeyHeight) -
(whiteKeyHeight * 0.25f)
val blackKeyRect = Rect(
left = size.width * 0.4f,
top = blackKeyY,
right = size.width,
bottom = blackKeyY + whiteKeyHeight * 0.5f
)
if (blackKeyRect.contains(offset)) {
return blackKey
}
}
// Затем проверяем белые клавиши
whiteKeys.forEachIndexed { index, whiteKey ->
val whiteKeyRect = Rect(
left = 0f,
top = index * whiteKeyHeight,
right = size.width,
bottom = (index + 1) * whiteKeyHeight
)
if (whiteKeyRect.contains(offset)) {
return whiteKey
}
}
return null
}
private fun getWhiteKeyIndexForBlackKey(blackKeyName: String, whiteKeys: List<Pair<String, Float>>): Int {
// Для черной клавиши находим соответствующую белую клавишу справа
val baseNote = blackKeyName.replace("#", "")
val whiteKeyIndex = whiteKeys.indexOfFirst { it.first == baseNote }
return if (whiteKeyIndex >= 0) whiteKeyIndex + 1 else 0
}
private fun DrawScope.drawHorizontalKeyboard(
whiteKeys: List<Pair<String, Float>>,
blackKeys: List<Pair<String, Float>>,
activeKeys: Set<String>,
size: Size
) {
val whiteKeyWidth = size.width / whiteKeys.size
// Рисуем белые клавиши
whiteKeys.forEachIndexed { index, (noteName, _) ->
val isActive = noteName in activeKeys
drawRect(
color = if (isActive) Color(0xFF64B5F6) else Color.White,
topLeft = Offset(x = index * whiteKeyWidth, y = 0f),
size = Size(width = whiteKeyWidth, height = size.height)
)
// Контур белой клавиши
drawRect(
color = Color.Black,
topLeft = Offset(x = index * whiteKeyWidth, y = 0f),
size = Size(width = 1f, height = size.height),
alpha = 0.3f
)
}
// Рисуем черные клавиши
blackKeys.forEachIndexed { index, (noteName, _) ->
val isActive = noteName in activeKeys
val whiteKeyIndex = getWhiteKeyIndexForBlackKey(noteName, whiteKeys)
val blackKeyX = (whiteKeyIndex * whiteKeyWidth) - (whiteKeyWidth * 0.25f)
drawRect(
color = if (isActive) Color(0xFF1976D2) else Color.Black,
topLeft = Offset(x = blackKeyX, y = 0f),
size = Size(width = whiteKeyWidth * 0.5f, height = size.height * 0.6f)
)
}
}
private fun DrawScope.drawVerticalKeyboard(
whiteKeys: List<Pair<String, Float>>,
blackKeys: List<Pair<String, Float>>,
activeKeys: Set<String>,
size: Size
) {
val whiteKeyHeight = size.height / whiteKeys.size
// Рисуем белые клавиши
whiteKeys.forEachIndexed { index, (noteName, _) ->
val isActive = noteName in activeKeys
drawRect(
color = if (isActive) Color(0xFF64B5F6) else Color.White,
topLeft = Offset(x = 0f, y = index * whiteKeyHeight),
size = Size(width = size.width, height = whiteKeyHeight)
)
// Контур белой клавиши
drawRect(
color = Color.Black,
topLeft = Offset(x = 0f, y = index * whiteKeyHeight),
size = Size(width = size.width, height = 1f),
alpha = 0.3f
)
}
// Рисуем черные клавиши
blackKeys.forEachIndexed { index, (noteName, _) ->
val isActive = noteName in activeKeys
val whiteKeyIndex = getWhiteKeyIndexForBlackKey(noteName, whiteKeys)
val blackKeyY = (whiteKeyIndex * whiteKeyHeight) - (whiteKeyHeight * 0.25f)
drawRect(
color = if (isActive) Color(0xFF1976D2) else Color.Black,
topLeft = Offset(x = size.width * 0.4f, y = blackKeyY),
size = Size(width = size.width * 0.6f, height = whiteKeyHeight * 0.5f)
)
}
}
@@ -0,0 +1,260 @@
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Build
import kotlin.math.*
import kotlin.random.Random
class RealisticTubaSynth(private val context: Context) {
companion object {
const val SAMPLE_RATE = 44100
const val CUTOFF_FREQ = 800.0
}
private var audioTrack: AudioTrack? = null
private var isPlaying = false
private var currentThread: Thread? = null
data class TubaNote(
val frequency: Float,
val amplitude: Float = 0.8f,
val durationMs: Int
)
fun playTubaNote(frequency: Float, durationMs: Int = 1500) {
// Останавливаем предыдущее воспроизведение
stop()
val note = TubaNote(frequency, 0.8f, durationMs)
generateTubaSound(note)
}
private fun generateTubaSound(note: TubaNote) {
currentThread = Thread {
isPlaying = true
val numSamples = note.durationMs * SAMPLE_RATE / 1000
val buffer = ShortArray(numSamples)
// Фильтр для шума дыхания
val alpha = 1 - exp(-2.0 * PI * CUTOFF_FREQ / SAMPLE_RATE)
var filteredNoise = 0.0
val random = Random(System.currentTimeMillis())
for (i in 0 until numSamples) {
if (!isPlaying) break
val time = i.toDouble() / SAMPLE_RATE
// Основной waveform тубы с более богатым спектром
val tubaWave = calculateTubaWaveform(note.frequency, time)
// Шум дыхания с фильтром низких частот
val whiteNoise = random.nextDouble() * 2 - 1
filteredNoise += alpha * (whiteNoise - filteredNoise)
// Огибающая с резкой атакой и быстрым спадом
val envelope = getRealisticTubaEnvelope(i, numSamples, note.frequency)
// Усиление басов
val bassBoost = 1.0 + 0.5 * sin(2 * PI * 60.0 * time)
// Смешиваем всё вместе
var sample = tubaWave + filteredNoise * 0.03 * envelope
sample *= envelope * bassBoost
// Ограничиваем амплитуду
sample = sample.coerceIn(-1.0, 1.0)
buffer[i] = (sample * Short.MAX_VALUE).toInt().toShort()
}
if (isPlaying) {
playAudioBuffer(buffer)
}
}.apply {
start()
}
}
private fun calculateTubaWaveform(freq: Float, time: Double): Double {
// Классификация нот по диапазонам
val range = when {
freq < 40 -> "pedal" // Педальные ноты (очень низкие)
freq < 80 -> "low" // Низкий диапазон
freq < 160 -> "middle" // Средний диапазон
freq < 240 -> "high" // Высокий диапазон
else -> "very_high" // Очень высокий диапазон
}
val fundamental = sin(2 * PI * freq * time)
// Разные наборы обертонов для разных диапазонов
val overtones = when (range) {
"pedal" -> {
// Педальные ноты: сильные низкие обертоны, меньше высоких
sin(2 * PI * freq * 2 * time) * 0.6 +
sin(2 * PI * freq * 3 * time) * 0.5 +
sin(2 * PI * freq * 4 * time) * 0.4 +
sin(2 * PI * freq * 5 * time) * 0.3
}
"low" -> {
// Низкий диапазон: богатый спектр
sin(2 * PI * freq * 2 * time) * 0.5 +
sin(2 * PI * freq * 3 * time) * 0.45 +
sin(2 * PI * freq * 4 * time) * 0.4 +
sin(2 * PI * freq * 5 * time) * 0.35 +
sin(2 * PI * freq * 6 * time) * 0.25
}
"middle" -> {
// Средний диапазон: сбалансированный спектр
sin(2 * PI * freq * 2 * time) * 0.4 +
sin(2 * PI * freq * 3 * time) * 0.4 +
sin(2 * PI * freq * 4 * time) * 0.35 +
sin(2 * PI * freq * 5 * time) * 0.3 +
sin(2 * PI * freq * 6 * time) * 0.25 +
sin(2 * PI * freq * 7 * time) * 0.2
}
"high" -> {
// Высокий диапазон: больше высоких обертонов
sin(2 * PI * freq * 2 * time) * 0.3 +
sin(2 * PI * freq * 3 * time) * 0.35 +
sin(2 * PI * freq * 4 * time) * 0.3 +
sin(2 * PI * freq * 5 * time) * 0.25 +
sin(2 * PI * freq * 6 * time) * 0.2 +
sin(2 * PI * freq * 7 * time) * 0.15 +
sin(2 * PI * freq * 8 * time) * 0.1
}
else -> {
// Очень высокий диапазон: яркий, с преобладанием высоких обертонов
sin(2 * PI * freq * 2 * time) * 0.25 +
sin(2 * PI * freq * 3 * time) * 0.3 +
sin(2 * PI * freq * 4 * time) * 0.25 +
sin(2 * PI * freq * 5 * time) * 0.2 +
sin(2 * PI * freq * 6 * time) * 0.15 +
sin(2 * PI * freq * 7 * time) * 0.1 +
sin(2 * PI * freq * 8 * time) * 0.08 +
sin(2 * PI * freq * 9 * time) * 0.05
}
}
val mixed = fundamental * 0.6 + overtones * 0.8
return tanh(mixed * 1.2) / 1.2
}
private fun getRealisticTubaEnvelope(sampleIndex: Int, totalSamples: Int, frequency: Float): Double {
val position = sampleIndex.toDouble() / totalSamples
// Разная огибающая для разных диапазонов
val (attack, decay, sustain, release) = when {
frequency < 40 -> arrayOf(0.04, 0.2, 0.6, 0.15) // Педальные ноты: медленнее
frequency < 100 -> arrayOf(0.025, 0.15, 0.65, 0.1) // Низкие: умеренные
frequency < 200 -> arrayOf(0.015, 0.1, 0.7, 0.08) // Средние: быстрее
else -> arrayOf(0.01, 0.08, 0.75, 0.06) // Высокие: очень быстрые
}
return when {
position < attack -> (position / attack).pow(0.3)
position < attack + decay -> {
val decayPos = (position - attack) / decay
1.0 - (1.0 - sustain) * decayPos
}
position > 1 - release -> {
val releasePos = (position - (1 - release)) / release
sustain * (1 - releasePos)
}
else -> sustain
}
}
private fun playAudioBuffer(buffer: ShortArray) {
try {
// Создаем новый AudioTrack каждый раз
val bufferSize = AudioTrack.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
val newAudioTrack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val attributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
val format = AudioFormat.Builder()
.setSampleRate(SAMPLE_RATE)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build()
AudioTrack.Builder()
.setAudioAttributes(attributes)
.setAudioFormat(format)
.setBufferSizeInBytes(bufferSize)
.build()
} else {
@Suppress("DEPRECATION")
AudioTrack(
AudioManager.STREAM_MUSIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize,
AudioTrack.MODE_STATIC
)
}
newAudioTrack.apply {
setVolume(0.8f)
// Используем MODE_STATIC для однократного воспроизведения
write(buffer, 0, buffer.size)
setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener {
override fun onMarkerReached(track: AudioTrack) {
// Автоматически освобождаем после воспроизведения
track.stop()
track.release()
}
override fun onPeriodicNotification(track: AudioTrack) {}
})
setNotificationMarkerPosition(buffer.size)
play()
}
audioTrack = newAudioTrack
} catch (e: Exception) {
e.printStackTrace()
isPlaying = false
}
}
fun stop() {
isPlaying = false
currentThread?.interrupt()
currentThread = null
audioTrack?.let { track ->
try {
if (track.playState == AudioTrack.PLAYSTATE_PLAYING) {
track.stop()
}
track.release()
} catch (e: IllegalStateException) {
// Игнорируем ошибки при остановке уже остановленного трека
e.printStackTrace()
}
}
audioTrack = null
}
fun release() {
stop()
}
}
@@ -0,0 +1,199 @@
package net.sergeych.karabass
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Build
import kotlin.math.*
import kotlin.random.Random
class TubaSynth(private val context: Context) {
companion object {
const val SAMPLE_RATE = 44100
const val CUTOFF_FREQ = 500.0
}
private var audioTrack: AudioTrack? = null
private var isPlaying = false
data class TubaNote(
val frequency: Float,
val amplitude: Float = 0.8f,
val durationMs: Int
)
fun playTubaNote(frequency: Float, durationMs: Int = 2000) {
if (isPlaying) {
stop()
}
val note = TubaNote(frequency, 0.8f, durationMs)
generateTubaSound(note)
}
private fun generateTubaSound(note: TubaNote) {
Thread {
isPlaying = true
val numSamples = note.durationMs * SAMPLE_RATE / 1000
val buffer = ShortArray(numSamples)
// Фильтр для шума дыхания
val alpha = 1 - exp(-2.0 * PI * CUTOFF_FREQ / SAMPLE_RATE)
var filteredNoise = 0.0
val random = Random(System.currentTimeMillis())
for (i in 0 until numSamples) {
if (!isPlaying) break
val time = i.toDouble() / SAMPLE_RATE
// Основной waveform тубы
val tubaWave = calculateTubaWaveform(note.frequency, time)
// Шум дыхания с фильтром низких частот
val whiteNoise = random.nextDouble() * 2 - 1
filteredNoise += alpha * (whiteNoise - filteredNoise)
// Огибающая
val envelope = getTubaEnvelope(i, numSamples)
// Усиление басов
val bassBoost = 1.0 + 0.4 * sin(2 * PI * 80.0 * time)
// Смешиваем всё вместе
var sample = tubaWave + filteredNoise * 0.05 * envelope
sample *= envelope * bassBoost
// Ограничиваем амплитуду
sample = sample.coerceIn(-1.0, 1.0)
buffer[i] = (sample * Short.MAX_VALUE).toInt().toShort()
}
if (isPlaying) {
playAudioBuffer(buffer)
}
}.start()
}
private fun calculateTubaWaveform(freq: Float, time: Double): Double {
return (
sin(2 * PI * freq * time) * 0.6 +
sin(2 * PI * freq * 2 * time) * 0.4 +
sin(2 * PI * freq * 3 * time) * 0.3 +
sin(2 * PI * freq * 4 * time) * 0.2 +
sin(2 * PI * freq * 5 * time) * 0.15 +
sin(2 * PI * freq * 6 * time) * 0.1 +
sin(2 * PI * freq * 7 * time) * 0.05
) * 0.6
}
private fun getTubaEnvelope(sampleIndex: Int, totalSamples: Int): Double {
val position = sampleIndex.toDouble() / totalSamples
val attack = 0.15
val decay = 0.1
val release = 0.3
return when {
position < attack -> {
val x = position / attack
x * x * (3 - 2 * x)
}
position < attack + decay -> {
val decayPos = (position - attack) / decay
0.9 + 0.1 * (1 - decayPos)
}
position > 1 - release -> {
val releasePos = (position - (1 - release)) / release
(1 - releasePos) * 0.9
}
else -> 0.9
}
}
private fun playAudioBuffer(buffer: ShortArray) {
try {
// Останавливаем предыдущее воспроизведение
audioTrack?.stop()
audioTrack?.release()
val bufferSize = AudioTrack.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
// Создаем AudioTrack с правильными параметрами
audioTrack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val attributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
val format = AudioFormat.Builder()
.setSampleRate(SAMPLE_RATE)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build()
AudioTrack.Builder()
.setAudioAttributes(attributes)
.setAudioFormat(format)
.setBufferSizeInBytes(bufferSize)
.build()
} else {
@Suppress("DEPRECATION")
AudioTrack(
AudioManager.STREAM_MUSIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize,
AudioTrack.MODE_STREAM
)
}
audioTrack?.apply {
// Устанавливаем громкость
setVolume(0.8f)
// Воспроизводим
play()
// Пишем данные
write(buffer, 0, buffer.size)
// Ждем окончания воспроизведения
setNotificationMarkerPosition(buffer.size)
setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener {
override fun onMarkerReached(track: AudioTrack) {
stop()
release()
isPlaying = false
}
override fun onPeriodicNotification(track: AudioTrack) {}
})
}
} catch (e: Exception) {
e.printStackTrace()
isPlaying = false
}
}
fun stop() {
isPlaying = false
audioTrack?.stop()
}
fun release() {
stop()
audioTrack?.release()
audioTrack = null
}
}
@@ -0,0 +1,11 @@
package net.sergeych.karabass.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,58 @@
package net.sergeych.karabass.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun KarabassTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,34 @@
package net.sergeych.karabass.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">karabass</string>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Karabass" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,17 @@
package net.sergeych.karabass
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}