mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 06:52:18 +02:00
fix(automix): ease effects into playback without cutting dry audio
This commit is contained in:
1 parent
d273be026d
commit
9ecfb31bc2
6 files changed
+129
-34
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user