Skip to content
Merged
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
50 changes: 50 additions & 0 deletions .github/workflows/gradle-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Gradle CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- uses: gradle/actions/setup-gradle@v6
- name: Build and test
run: ./gradlew build test

distro:
strategy:
matrix:
include:
- os: ubuntu-latest
format: Deb
name: linux
- os: windows-latest
format: Msi
name: windows
- os: macos-latest
format: Dmg
name: macos
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- uses: gradle/actions/setup-gradle@v6
- name: Build ${{ matrix.name }} distributable
run: ./gradlew package${{ matrix.format }}
- uses: actions/upload-artifact@v6
with:
name: wren-${{ matrix.name }}
path: build/compose/binaries/main/*
4 changes: 2 additions & 2 deletions build-appimage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ Categories=AudioVideo;Music;
EOF

# Icon — use provided one or generate a placeholder
if [ -f "icon.png" ]; then
cp icon.png "$APP_DIR/${APP_NAME}.png"
if [ -f "wren.png" ]; then
cp wren.png "$APP_DIR/${APP_NAME}.png"
else
echo ">>> No icon.png found, generating placeholder..."
# Try to generate with ImageMagick, fall back to a minimal PNG
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ compose.desktop {
application {
mainClass = "MainKt"
nativeDistributions {
targetFormats(TargetFormat.Deb, TargetFormat.Rpm)
targetFormats(TargetFormat.Deb, TargetFormat.Rpm, TargetFormat.Msi, TargetFormat.Dmg)
packageName = "wren"
packageVersion = "1.0.0"
modules("java.net.http")
Expand Down
49 changes: 9 additions & 40 deletions src/main/kotlin/player/FFmpegPlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import api.warmupStreamConnection
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.*
import org.bytedeco.ffmpeg.global.avutil
import org.bytedeco.javacv.FFmpegFrameGrabber
import java.nio.ByteBuffer
import java.nio.ShortBuffer
Expand Down Expand Up @@ -208,7 +207,7 @@ class FFmpegPlayer {
// Create and configure grabber
val newGrabber = FFmpegFrameGrabber(streamUrl)
newGrabber.setAudioChannels(2)
newGrabber.setPixelFormat(avutil.AV_SAMPLE_FMT_S16)
newGrabber.setOption("sample_fmt", "s16")
newGrabber.setOption("re", "1")
newGrabber.setOption("timeout", "30000000")

Expand Down Expand Up @@ -302,18 +301,15 @@ class FFmpegPlayer {
val (bytes, len) = bufferToBytes(sampleBuffer)
if (len > 0) {
val vol = digitalVolume
if (vol >= 1.0f) {
line.write(bytes, 0, len)
} else {
// Apply digital volume gain to S16 samples (little-endian)
if (vol < 1.0f) {
for (i in 0 until len step 2) {
val sample = bytes[i].toInt() or (bytes[i + 1].toInt() shl 8)
val scaled = (sample * vol).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
bytes[i] = scaled.toByte()
bytes[i + 1] = (scaled shr 8).toByte()
val sample = ((bytes[i].toInt() and 0xFF) or ((bytes[i + 1].toInt() and 0xFF) shl 8)).toShort()
val scaled = (sample.toFloat() * vol).coerceIn(Short.MIN_VALUE.toFloat(), Short.MAX_VALUE.toFloat())
bytes[i] = scaled.toInt().toByte()
bytes[i + 1] = (scaled.toInt() ushr 8).toByte()
}
line.write(bytes, 0, len)
}
line.write(bytes, 0, len)
}
}
}
Expand Down Expand Up @@ -496,36 +492,9 @@ class FFmpegPlayer {

fun setVolume(vol: Int) {
volume.value = vol
// Apply via FloatControl with logarithmic curve for natural volume perception
synchronized(audioLock) {
val line = audioLine ?: return
try {
val gainControl = line.getControl(FloatControl.Type.MASTER_GAIN) as? FloatControl
if (gainControl != null) {
// Logarithmic mapping: human hearing is logarithmic
// At vol=0, gain=min dB (silent). At vol=100, gain=max dB (0dB).
// Using log curve so small changes at low volume are perceptible.
val minDb = gainControl.minimum
val maxDb = gainControl.maximum
val gainDb = if (vol <= 0) {
minDb
} else {
val ratio = vol / 100f
// log10(0.0001) = -4, maps ratio 0..1 to -40..0 dB, then scale to range
val logRatio = kotlin.math.log10(0.0001f + ratio * 0.9999f) / 4f
minDb + logRatio * (maxDb - minDb)
}
gainControl.value = gainDb
return
}
} catch (_: Exception) {
// Some mixers don't support MASTER_GAIN, fall through to digital gain
}
// Fallback: digital gain applied in playLoop
digitalVolume = vol / 100f
}
digitalVolume = vol / 100f
}

@Volatile
private var digitalVolume: Float = 0.5f
private var digitalVolume: Float = 1.0f
}
Loading