new: add playback speed control

This commit is contained in:
2026-07-03 10:37:28 +02:00
parent 9aaffdc363
commit 3847f46adb
20 changed files with 618 additions and 80 deletions
@@ -61,6 +61,10 @@ internal class AndroidAudioPlayer(
player.prepare()
}
override fun setSpeed(speed: Float) {
player.setPlaybackSpeed(speed)
}
override fun play() {
player.play()
}
@@ -22,4 +22,7 @@ interface AudioPlayer {
fun release()
fun load(uri: String)
/** Sets the playback speed multiplier (e.g. 1.0 for normal speed, 0.5 for half speed). */
fun setSpeed(speed: Float)
}
@@ -6,14 +6,16 @@ import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import platform.AVFoundation.AVAudioTimePitchAlgorithmTimeDomain
import platform.AVFoundation.AVPlayer
import platform.AVFoundation.AVPlayerItem
import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification
import platform.AVFoundation.audioTimePitchAlgorithm
import platform.AVFoundation.currentItem
import platform.AVFoundation.currentTime
import platform.AVFoundation.duration
import platform.AVFoundation.pause
import platform.AVFoundation.play
import platform.AVFoundation.rate
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
import platform.AVFoundation.seekToTime
import platform.CoreMedia.CMTimeGetSeconds
@@ -29,6 +31,7 @@ import kotlin.time.Duration.Companion.seconds
internal class IosAudioPlayer : AudioPlayer {
private var player: AVPlayer? = null
private var endObserver: NSObjectProtocol? = null
private var speed: Float = 1f
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
@@ -63,6 +66,8 @@ internal class IosAudioPlayer : AudioPlayer {
val nsUrl = NSURL.URLWithString(uri) ?: error("Invalid audio URI: $uri")
val item = AVPlayerItem(uRL = nsUrl)
// Time-domain stretching preserves the pitch when the rate is below 1.0.
item.audioTimePitchAlgorithm = AVAudioTimePitchAlgorithmTimeDomain
player = AVPlayer(playerItem = item)
endObserver = NSNotificationCenter.defaultCenter.addObserverForName(
@@ -75,10 +80,18 @@ internal class IosAudioPlayer : AudioPlayer {
}
override fun play() {
player?.play()
// Setting a positive rate resumes playback; using the stored speed keeps the chosen tempo.
player?.rate = speed
_playbackState.value = PlaybackState.PLAYING
}
override fun setSpeed(speed: Float) {
this.speed = speed
if (_playbackState.value == PlaybackState.PLAYING) {
player?.rate = speed
}
}
override fun pause() {
player?.pause()
_playbackState.value = PlaybackState.PAUSED
@@ -10,6 +10,7 @@ import java.net.URI
import javax.sound.sampled.AudioFormat
import javax.sound.sampled.AudioSystem
import javax.sound.sampled.Clip
import javax.sound.sampled.FloatControl
import javax.sound.sampled.LineEvent
import kotlin.time.Duration
import kotlin.time.Duration.Companion.microseconds
@@ -26,6 +27,9 @@ internal class JvmAudioPlayer : AudioPlayer {
private var stoppedByUser = false
private var speed: Float = 1f
private var baseSampleRate: Float = 0f
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
@@ -77,6 +81,23 @@ internal class JvmAudioPlayer : AudioPlayer {
}
open(decodedInput)
}
baseSampleRate = pcmFormat.sampleRate
applySpeed()
}
override fun setSpeed(speed: Float) {
this.speed = speed
applySpeed()
}
/**
* Adjusts the tempo through the clip's sample-rate control. Unlike the other platforms this does
* not preserve pitch, and it is a no-op when the mixer does not expose the control.
*/
private fun applySpeed() {
val clip = clip ?: return
if (baseSampleRate <= 0f || !clip.isControlSupported(FloatControl.Type.SAMPLE_RATE)) return
(clip.getControl(FloatControl.Type.SAMPLE_RATE) as FloatControl).value = baseSampleRate * speed
}
override fun play() {
@@ -12,6 +12,7 @@ import kotlin.time.Duration.Companion.seconds
internal class WebAudioPlayer : AudioPlayer {
private var audio: HTMLAudioElement? = null
private var speed: Float = 1f
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
@@ -31,12 +32,19 @@ internal class WebAudioPlayer : AudioPlayer {
audio = (document.createElement("audio") as HTMLAudioElement).apply {
src = uri
playbackRate = speed.toDouble()
addEventListener("play") { _playbackState.value = PlaybackState.PLAYING }
addEventListener("ended") { _playbackState.value = PlaybackState.ENDED }
addEventListener("pause") { _playbackState.value = PlaybackState.PAUSED }
}
}
override fun setSpeed(speed: Float) {
// Browsers preserve pitch (preservesPitch defaults to true) when changing playbackRate.
this.speed = speed
audio?.playbackRate = speed.toDouble()
}
override fun play() {
audio?.play()
}