Files
SpotiFLAC-Mobile/lib/services/automix_analysis.dart
T

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),
);
}