fix(automix): ease effects into playback without cutting dry audio

This commit is contained in:
zarzet committed 2026-10-10 17:06:41 +07:00
1 parent d273be026d
commit 9ecfb31bc2
6 files changed
+129 -34

No files matched your search

+4 -2
View File
@@ -235,10 +235,12 @@ class AutoMixPlan {
..sort((a, b) => (a - 1).abs().compareTo((b - 1).abs()));
final candidate = candidates.first;
final firstBeat = incoming.beatAtOrAfter(incoming.firstSound);
// Never stretch wildly or discard a long musical intro to force a mix.
// The detector measures leading silence, not the length of a musical
// intro. A quiet lead-in should not disable an otherwise reliable mix.
// Bound the trim so an uncertain/very late onset still uses crossfade.
if ((candidate - 1).abs() > 0.08) {
fallbackReason = AutoMixFallbackReason.incompatibleTempo;
} else if (firstBeat > 3) {
} else if (firstBeat > math.min(12, nextLength * 0.1)) {
fallbackReason = AutoMixFallbackReason.longIntro;
} else {
rate = candidate;
+18 -8
View File
@@ -8,13 +8,17 @@ import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/models/automix_options.dart';
import 'package:spotiflac_android/services/audio_analysis_jobs.dart';
/// Independent pitch and tempo controls, followed by optional transition FX.
/// Keep the beginning dry for the live deck handover, then ease in the FX.
String autoMixEffectFilters(AutoMixOptions options, Duration duration) {
final seconds = duration.inMicroseconds / 1e6;
final pitchRatio = pow(2, options.effectivePitch / 12).toDouble();
return [
'aresample=48000',
'aformat=sample_fmts=fltp:channel_layouts=stereo',
final ramp = min(1.5, seconds / 3);
final progress = 'clip((t-0.24)/${ramp.toStringAsFixed(6)},0,1)';
final wet = '($progress*$progress*(3-2*$progress))';
final bounds =
'apad=whole_dur=${seconds.toStringAsFixed(6)},'
'atrim=duration=${seconds.toStringAsFixed(6)}';
final effects = [
if (options.effectivePitch != 0) ...[
'asetrate=${(48000 * pitchRatio).toStringAsFixed(6)}',
'aresample=48000',
@@ -23,11 +27,17 @@ String autoMixEffectFilters(AutoMixOptions options, Duration duration) {
'atempo=${(options.effectiveSpeed / pitchRatio).toStringAsFixed(6)}',
if (options.useLowPass) 'lowpass=f=1200',
if (options.useEcho) 'aecho=0.6:0.75:180|360:0.3|0.15',
'afade=t=in:d=0.015',
'apad=whole_dur=${seconds.toStringAsFixed(6)}',
'atrim=duration=${seconds.toStringAsFixed(6)}',
'alimiter=limit=0.98:level=false:latency=true',
bounds,
"volume='$wet':eval=frame",
].join(',');
// Complementary gains preserve the dry signal's level while the two
// branches are still correlated. The limiter bounds combined FX peaks.
return 'aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,'
'asplit=2[dry][wet];'
'[dry]$bounds,volume=\'1-$wet\':eval=frame[clean];'
'[wet]$effects[effect];'
'[clean][effect]amix=inputs=2:normalize=0,'
'alimiter=limit=0.98:level=false:latency=true';
}
class RenderedMixTail {
+27 -11
View File
@@ -12,6 +12,7 @@ class _MusicAutoMix {
final _mixPins = <String>{};
MusicPlaybackDeck? _prepared;
MusicPlaybackDeck? _outgoing;
MusicPlaybackDeck? _effectDry;
MusicPlaybackDeck? _effectPrepared;
RenderedMixTail? _effectTail;
AutoMixPlan? _plan;
@@ -173,7 +174,7 @@ class _MusicAutoMix {
try {
await effectDeck.setReleaseMode(ReleaseMode.stop);
await effectDeck.setAudioContext(_musicAudioContext);
await effectDeck.setVolume(handler._normalizationVolume);
await effectDeck.setVolume(0);
await effectDeck.setSource(DeviceFileSource(effectTail.path));
} catch (error) {
_log.w('AutoMix effect preparation fallback: $error');
@@ -253,15 +254,11 @@ class _MusicAutoMix {
try {
await effectDeck.seek(delta.isNegative ? Duration.zero : delta);
if (!_current(generation)) return;
await original.setVolume(0);
await effectDeck.resume();
if (!_current(generation)) {
await original.setVolume(outgoingVolume);
return;
}
if (!_current(generation)) return;
outgoing = effectDeck;
_effectDry = original;
} catch (error) {
await original.setVolume(outgoingVolume);
if (!_current(generation)) return;
_log.w('AutoMix effect fallback: $error');
await handler._disposeDeck(effectDeck);
@@ -277,9 +274,6 @@ class _MusicAutoMix {
_setStatus(
plan.beatMatched ? AutoMixStatus.mixing : AutoMixStatus.crossfading,
);
if (!identical(outgoing, original)) {
unawaited(handler._disposeDeck(original));
}
handler._index = _nextIndex;
handler._playRequestGeneration++;
handler._recordPlayHistory(_nextIndex);
@@ -305,10 +299,29 @@ class _MusicAutoMix {
_fadeWrite = () async {
try {
final levels = autoMixEnvelope(clock.elapsed, plan);
final dry = _effectDry;
final progress = (clock.elapsedMicroseconds / 240000).clamp(
0.0,
1.0,
);
final handover = progress * progress * (3 - 2 * progress);
if (dry != null) {
await dry.setVolume(
outgoingVolume * levels.outgoing * (1 - handover),
);
if (generation != _generation) return;
}
if (_outgoing != null) {
await outgoing.setVolume(outgoingVolume * levels.outgoing);
await outgoing.setVolume(
outgoingVolume * levels.outgoing * (dry == null ? 1 : handover),
);
}
if (generation != _generation) return;
if (dry != null && progress >= 1) {
_effectDry = null;
await handler._disposeDeck(dry);
if (generation != _generation) return;
}
await incoming.setVolume(_incomingVolume * levels.incoming);
if (generation != _generation) return;
if ((levels.rate - rate).abs() > 0.001 || levels.complete) {
@@ -360,11 +373,13 @@ class _MusicAutoMix {
_fadeTimer = null;
final prepared = _prepared;
final outgoing = _outgoing;
final effectDry = _effectDry;
final effectPrepared = _effectPrepared;
final effectTail = _effectTail;
final restore = outgoing != null || rate != 1;
_prepared = null;
_outgoing = null;
_effectDry = null;
_effectPrepared = null;
_effectTail = null;
_plan = null;
@@ -378,6 +393,7 @@ class _MusicAutoMix {
await _fadeWrite;
if (prepared != null) await handler._disposeDeck(prepared);
if (outgoing != null) await handler._disposeDeck(outgoing);
if (effectDry != null) await handler._disposeDeck(effectDry);
if (effectPrepared != null) await handler._disposeDeck(effectPrepared);
await effectTail?.dispose();
if (restore && !handler._disposed) {
+21 -1
View File
@@ -107,7 +107,7 @@ void main() {
bpm: 120,
phase: 0.17,
confidence: 0.9,
firstSound: 4,
firstSound: 14,
),
)!;
expect(late.fallbackReason, AutoMixFallbackReason.longIntro);
@@ -133,6 +133,26 @@ void main() {
);
});
test('a silent lead-in does not prevent reliable beat matching', () {
final transition = AutoMixPlan.create(
outgoingDuration: const Duration(minutes: 3),
incomingDuration: const Duration(minutes: 3),
outro: grid(120),
intro: const AutoMixBeatGrid(
bpm: 124,
phase: 0.17,
confidence: 0.9,
firstSound: 5,
),
)!;
expect(transition.beatMatched, isTrue);
expect(
transition.incomingStart.inMilliseconds,
inInclusiveRange(5000, 5500),
);
expect(transition.rate, closeTo(120 / 124, 0.00001));
});
test('equal-power overlap restores normal tempo smoothly', () {
final transition = plan(120, 124);
final start = autoMixEnvelope(Duration.zero, transition);
+23 -12
View File
@@ -113,7 +113,7 @@ void main() {
const AutoMixOptions(effect: AutoMixEffect.pitch, pitchSemitones: 2),
);
var crossings = 0;
for (var i = 24001; i < 72000; i++) {
for (var i = 96001; i < 144000; i++) {
if (pitched[i - 1] <= 0 && pitched[i] > 0) crossings++;
}
expect(crossings, closeTo(440 * pow(2, 2 / 12), 2));
@@ -121,24 +121,35 @@ void main() {
'sine=frequency=5000:sample_rate=48000',
const AutoMixOptions(effect: AutoMixEffect.muffled),
);
final rms = sqrt(
filtered
.skip(24000)
.take(48000)
.fold<double>(0, (sum, value) => sum + value * value) /
48000,
);
expect(rms, lessThan(0.01));
double rms(double from, double to) {
final count = ((to - from) * 48000).round();
return sqrt(
filtered
.skip((from * 48000).round())
.take(count)
.fold<double>(0, (sum, value) => sum + value * value) /
count,
);
}
// The handover starts with the original signal, then progressively
// removes highs rather than switching to a muffled signal at t=0.
final dry = rms(0.03, 0.15);
final easing = rms(0.6, 0.7);
final wet = rms(2, 3);
expect(dry, greaterThan(0.08));
expect(easing, inExclusiveRange(wet, dry));
expect(wet, lessThan(0.01));
final echo = await signal(
"aevalsrc=if(between(t\\,0.1\\,0.101)\\,0.5\\,0):s=48000",
"aevalsrc=if(between(t\\,1.5\\,1.501)\\,0.5\\,0):s=48000",
const AutoMixOptions(effect: AutoMixEffect.echo),
);
double peak(double time) => echo
.skip((time * 48000).round())
.take(100)
.fold<double>(0, (p, value) => max(p, value.abs()));
expect(peak(0.28), greaterThan(0.05));
expect(peak(0.46), greaterThan(0.02));
expect(peak(1.68), greaterThan(0.05));
expect(peak(1.86), greaterThan(0.02));
renderer.dispose();
},
);
+36
View File
@@ -71,6 +71,7 @@ class _AudioNative {
final sourceGates = <String, Completer<void>>{};
final resumedSources = <String>[];
Completer<void>? resumeGate;
final resumeGates = <String, Completer<void>>{};
bool emitsDuration = true;
void install() {
@@ -112,6 +113,7 @@ class _AudioNative {
unawaited(event(id, 'audio.onSeekComplete'));
case 'resume':
await resumeGate?.future;
await resumeGates[id]?.future;
playing.add(id);
resumedSources.add(sources[id]!);
case 'pause' || 'stop':
@@ -1515,6 +1517,40 @@ void main() {
expect(effectRenderer.tails, isEmpty);
});
test(
'effect startup keeps dry audio audible and blends before disposal',
() async {
setAutoMixOptions(const AutoMixOptions(effect: AutoMixEffect.muffled));
await prepare();
await _until(
() => native.sources.values.any((path) => path.endsWith('/tail.wav')),
);
final effect = native.sources.entries
.singleWhere((entry) => entry.value.endsWith('/tail.wav'))
.key;
final ready = Completer<void>();
native.resumeGates[effect] = ready;
native.positions['music-player'] = 55000;
await _until(
() => native.calls.any(
(call) => call.$1 == effect && call.$2 == 'resume',
),
);
expect(native.lastVolume('music-player'), 1);
expect(native.lastVolume(effect), 0);
expect(native.playing, contains('music-player'));
ready.complete();
await _until(() => (native.lastVolume(effect) ?? 0) > 0);
expect(native.lastVolume('music-player'), inExclusiveRange(0, 1));
expect(native.lastVolume(effect), inExclusiveRange(0, 1));
// Pausing during the handover must clean up both the dry and wet decks.
await handler.pause();
expect(native.live, hasLength(1));
expect(native.playing, isEmpty);
expect(await effectRenderer.tails.single.directory.exists(), isFalse);
},
);
test(
'turning AutoMix off releases the standby player without changing song',
() async {