mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 14:37:33 +02:00
286 lines
8.8 KiB
Dart
286 lines
8.8 KiB
Dart
import 'dart:math' as math;
|
|
import 'dart:typed_data';
|
|
import 'package:spotiflac_android/models/automix_options.dart';
|
|
|
|
/// A local beat grid, measured in seconds within the decoded audio window.
|
|
class AutoMixBeatGrid {
|
|
const AutoMixBeatGrid({
|
|
required this.bpm,
|
|
required this.phase,
|
|
required this.confidence,
|
|
required this.firstSound,
|
|
});
|
|
|
|
final double bpm;
|
|
final double phase;
|
|
final double confidence;
|
|
final double firstSound;
|
|
|
|
bool get reliable => confidence >= 0.55 && bpm >= 60 && bpm <= 180;
|
|
double get period => 60 / bpm;
|
|
double beatAtOrAfter(double seconds) =>
|
|
phase + ((seconds - phase) / period).ceil() * period;
|
|
}
|
|
|
|
/// Bounded, original onset/autocorrelation detector. Runs in an isolate on
|
|
/// 24-second mono PCM windows, not on the UI isolate or an entire music file.
|
|
/// The onset -> autocorrelation -> phase approach is described in DAFx-09,
|
|
/// "Real-time beat-synchronous analysis of musical audio":
|
|
/// https://www.dafx.de/paper-archive/2009/papers/paper_65.pdf
|
|
AutoMixBeatGrid analyzeAutoMixPcm(Uint8List pcm) {
|
|
const sampleRate = 11025;
|
|
const hop = 128;
|
|
const framesPerSecond = sampleRate / hop;
|
|
final samples = ByteData.sublistView(pcm);
|
|
final frames = pcm.length ~/ (2 * hop);
|
|
const unknown = AutoMixBeatGrid(
|
|
bpm: 0,
|
|
phase: 0,
|
|
confidence: 0,
|
|
firstSound: 0,
|
|
);
|
|
if (frames < framesPerSecond * 6) return unknown;
|
|
final onset = Float64List(frames);
|
|
final energy = Float64List(frames);
|
|
var low = 0.0;
|
|
var mid = 0.0;
|
|
var previousLow = 0.0;
|
|
var previousMid = 0.0;
|
|
var previousHigh = 0.0;
|
|
for (var frame = 0; frame < frames; frame++) {
|
|
var loEnergy = 0.0;
|
|
var midEnergy = 0.0;
|
|
var hiEnergy = 0.0;
|
|
for (var i = 0; i < hop; i++) {
|
|
final value =
|
|
samples.getInt16((frame * hop + i) * 2, Endian.little) / 32768;
|
|
low += 0.075 * (value - low);
|
|
mid += 0.45 * (value - mid);
|
|
loEnergy += low * low;
|
|
midEnergy += (mid - low) * (mid - low);
|
|
hiEnergy += (value - mid) * (value - mid);
|
|
energy[frame] += value * value / hop;
|
|
}
|
|
final lo = math.log(1 + loEnergy * 100 / hop);
|
|
final mi = math.log(1 + midEnergy * 100 / hop);
|
|
final hi = math.log(1 + hiEnergy * 100 / hop);
|
|
onset[frame] =
|
|
math.max(0, lo - previousLow) +
|
|
math.max(0, mi - previousMid) +
|
|
0.5 * math.max(0, hi - previousHigh);
|
|
previousLow = lo;
|
|
previousMid = mi;
|
|
previousHigh = hi;
|
|
}
|
|
final peakEnergy = energy.reduce(math.max);
|
|
if (peakEnergy < 0.00001) return unknown;
|
|
final firstAudible = energy.indexWhere(
|
|
(e) => e > math.max(0.00001, peakEnergy * 0.01),
|
|
);
|
|
final firstSound = math.max(0, firstAudible) / framesPerSecond;
|
|
// Suppress slow volume changes and constant noise before finding the pulse.
|
|
final prefix = Float64List(frames + 1);
|
|
for (var i = 0; i < frames; i++) {
|
|
prefix[i + 1] = prefix[i] + onset[i];
|
|
}
|
|
var total = 0.0;
|
|
for (var i = 0; i < frames; i++) {
|
|
final begin = math.max(0, i - 8);
|
|
final end = math.min(frames, i + 9);
|
|
onset[i] = math.max(
|
|
0,
|
|
onset[i] - (prefix[end] - prefix[begin]) / (end - begin),
|
|
);
|
|
total += onset[i] * onset[i];
|
|
}
|
|
if (total < 0.000001) {
|
|
return AutoMixBeatGrid(
|
|
bpm: 0,
|
|
phase: 0,
|
|
confidence: 0,
|
|
firstSound: firstSound,
|
|
);
|
|
}
|
|
|
|
double correlation(double lag) {
|
|
var dot = 0.0;
|
|
var left = 0.0;
|
|
var right = 0.0;
|
|
for (var i = lag.ceil(); i < frames; i++) {
|
|
final source = i - lag;
|
|
final j = source.floor();
|
|
final fraction = source - j;
|
|
final delayed =
|
|
onset[j] * (1 - fraction) +
|
|
onset[math.min(j + 1, frames - 1)] * fraction;
|
|
dot += onset[i] * delayed;
|
|
left += onset[i] * onset[i];
|
|
right += delayed * delayed;
|
|
}
|
|
return dot / math.sqrt(math.max(0.000000001, left * right));
|
|
}
|
|
|
|
var bestBpm = 0.0;
|
|
var bestScore = 0.0;
|
|
for (var bpm = 60.0; bpm <= 180; bpm += 0.25) {
|
|
final score = correlation(framesPerSecond * 60 / bpm);
|
|
// Resolve equally strong half-time candidates toward common musical tempi.
|
|
final weighted =
|
|
score * (0.96 + 0.04 * math.exp(-math.pow((bpm - 120) / 45, 2)));
|
|
if (weighted > bestScore) {
|
|
bestScore = weighted;
|
|
bestBpm = bpm;
|
|
}
|
|
}
|
|
if (bestBpm == 0) return unknown;
|
|
final period = framesPerSecond * 60 / bestBpm;
|
|
var bestPhase = 0.0;
|
|
var phaseScore = 0.0;
|
|
for (var phase = 0.0; phase < period; phase += 0.5) {
|
|
var score = 0.0;
|
|
var count = 0;
|
|
for (var beat = phase; beat < frames - 1; beat += period) {
|
|
final i = beat.round();
|
|
score += onset[i];
|
|
count++;
|
|
}
|
|
score /= math.max(1, count);
|
|
if (score > phaseScore) {
|
|
phaseScore = score;
|
|
bestPhase = phase;
|
|
}
|
|
}
|
|
var hits = 0;
|
|
var beats = 0;
|
|
for (var beat = bestPhase; beat < frames - 2; beat += period) {
|
|
final i = beat.round();
|
|
final peak = math.max(
|
|
onset[i],
|
|
math.max(onset[math.max(0, i - 1)], onset[i + 1]),
|
|
);
|
|
if (peak >= phaseScore * 0.3) hits++;
|
|
beats++;
|
|
}
|
|
final coverage = hits / math.max(1, beats);
|
|
return AutoMixBeatGrid(
|
|
bpm: bestBpm,
|
|
phase: bestPhase / framesPerSecond,
|
|
confidence: math.min(bestScore, coverage),
|
|
firstSound: firstSound,
|
|
);
|
|
}
|
|
|
|
enum AutoMixFallbackReason {
|
|
crossfadePreset,
|
|
analysisUnavailable,
|
|
unreliableBeats,
|
|
incompatibleTempo,
|
|
longIntro,
|
|
}
|
|
|
|
class AutoMixPlan {
|
|
const AutoMixPlan({
|
|
required this.start,
|
|
required this.incomingStart,
|
|
required this.duration,
|
|
required this.rate,
|
|
required this.beatMatched,
|
|
this.fallbackReason,
|
|
});
|
|
|
|
final Duration start;
|
|
final Duration incomingStart;
|
|
final Duration duration;
|
|
final double rate;
|
|
final bool beatMatched;
|
|
final AutoMixFallbackReason? fallbackReason;
|
|
|
|
static AutoMixPlan? create({
|
|
required Duration outgoingDuration,
|
|
required Duration incomingDuration,
|
|
AutoMixBeatGrid? outro,
|
|
AutoMixBeatGrid? intro,
|
|
double outroOffset = 0,
|
|
AutoMixOptions options = const AutoMixOptions(),
|
|
}) {
|
|
final end = outgoingDuration.inMicroseconds / 1e6;
|
|
final nextLength = incomingDuration.inMicroseconds / 1e6;
|
|
if (end < 20 || nextLength < 20) return null;
|
|
final manualDuration = options.safeDurationSeconds;
|
|
final maximum = math.min(60.0, math.min(end / 2, nextLength / 2));
|
|
var fade = (manualDuration == 0 ? 5.0 : manualDuration.toDouble()).clamp(
|
|
3.0,
|
|
maximum,
|
|
);
|
|
var start = end - fade;
|
|
var incomingStart = 0.0;
|
|
var rate = 1.0;
|
|
var matched = false;
|
|
AutoMixFallbackReason? fallbackReason;
|
|
if (options.effect == AutoMixEffect.crossfade) {
|
|
fallbackReason = AutoMixFallbackReason.crossfadePreset;
|
|
} else if (outro == null || intro == null) {
|
|
fallbackReason = AutoMixFallbackReason.analysisUnavailable;
|
|
} else if (!outro.reliable || !intro.reliable) {
|
|
fallbackReason = AutoMixFallbackReason.unreliableBeats;
|
|
} else {
|
|
final outgoing = outro;
|
|
final incoming = intro;
|
|
final candidates =
|
|
[
|
|
0.5,
|
|
1.0,
|
|
2.0,
|
|
].map((factor) => outgoing.bpm / (incoming.bpm * factor)).toList()
|
|
..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.
|
|
if ((candidate - 1).abs() > 0.08) {
|
|
fallbackReason = AutoMixFallbackReason.incompatibleTempo;
|
|
} else if (firstBeat > 3) {
|
|
fallbackReason = AutoMixFallbackReason.longIntro;
|
|
} else {
|
|
rate = candidate;
|
|
if (manualDuration == 0) {
|
|
fade = (8 * outgoing.period).clamp(3.0, math.min(8.0, maximum));
|
|
}
|
|
final phase = outroOffset + outgoing.phase;
|
|
start =
|
|
phase +
|
|
((end - fade - phase) / outgoing.period).floor() * outgoing.period;
|
|
incomingStart = firstBeat;
|
|
matched = true;
|
|
}
|
|
}
|
|
return AutoMixPlan(
|
|
start: Duration(microseconds: (start * 1e6).round()),
|
|
incomingStart: Duration(microseconds: (incomingStart * 1e6).round()),
|
|
duration: Duration(microseconds: (fade * 1e6).round()),
|
|
rate: rate,
|
|
beatMatched: matched,
|
|
fallbackReason: fallbackReason,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Equal-power gain ramps; tempo returns gradually after the outgoing song ends.
|
|
({double outgoing, double incoming, double rate, bool complete})
|
|
autoMixEnvelope(Duration elapsed, AutoMixPlan plan) {
|
|
final t = (elapsed.inMicroseconds / plan.duration.inMicroseconds).clamp(
|
|
0.0,
|
|
1.0,
|
|
);
|
|
final recovery = ((elapsed - plan.duration).inMicroseconds / 8000000).clamp(
|
|
0.0,
|
|
1.0,
|
|
);
|
|
final smooth = recovery * recovery * (3 - 2 * recovery);
|
|
return (
|
|
outgoing: math.cos(t * math.pi / 2),
|
|
incoming: math.sin(t * math.pi / 2),
|
|
rate: plan.rate + (1 - plan.rate) * smooth,
|
|
complete: recovery >= 1 || (t >= 1 && plan.rate == 1),
|
|
);
|
|
}
|