diff --git a/lib/services/automix_analysis.dart b/lib/services/automix_analysis.dart index 3edc893a..2f26be43 100644 --- a/lib/services/automix_analysis.dart +++ b/lib/services/automix_analysis.dart @@ -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; diff --git a/lib/services/automix_effect_renderer.dart b/lib/services/automix_effect_renderer.dart index 67de8f00..d105060e 100644 --- a/lib/services/automix_effect_renderer.dart +++ b/lib/services/automix_effect_renderer.dart @@ -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 { diff --git a/lib/services/music_player_automix.dart b/lib/services/music_player_automix.dart index 3aaac83a..f0555958 100644 --- a/lib/services/music_player_automix.dart +++ b/lib/services/music_player_automix.dart @@ -12,6 +12,7 @@ class _MusicAutoMix { final _mixPins = {}; 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) { diff --git a/test/automix_analysis_test.dart b/test/automix_analysis_test.dart index aaa32665..dedd891e 100644 --- a/test/automix_analysis_test.dart +++ b/test/automix_analysis_test.dart @@ -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); diff --git a/test/automix_effect_renderer_test.dart b/test/automix_effect_renderer_test.dart index 43aa50cf..24cb76d4 100644 --- a/test/automix_effect_renderer_test.dart +++ b/test/automix_effect_renderer_test.dart @@ -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(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(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(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(); }, ); diff --git a/test/music_player_automix_test.dart b/test/music_player_automix_test.dart index 9c9460c7..01c91537 100644 --- a/test/music_player_automix_test.dart +++ b/test/music_player_automix_test.dart @@ -71,6 +71,7 @@ class _AudioNative { final sourceGates = >{}; final resumedSources = []; Completer? resumeGate; + final resumeGates = >{}; 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(); + 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 {