mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 06:52:18 +02:00
170 lines
5.7 KiB
Dart
170 lines
5.7 KiB
Dart
import 'dart:math' as math;
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:spotiflac_android/models/automix_options.dart';
|
|
import 'package:spotiflac_android/models/settings.dart';
|
|
import 'package:spotiflac_android/services/automix_analysis.dart';
|
|
|
|
Uint8List drumPattern(double bpm, {double phase = 0.17}) {
|
|
const sampleRate = 11025;
|
|
final bytes = ByteData(sampleRate * 24 * 2);
|
|
final random = math.Random(7);
|
|
for (var i = 0; i < sampleRate * 24; i++) {
|
|
final time = i / sampleRate;
|
|
final sinceBeat = (time - phase) % (60 / bpm);
|
|
final transient = time < phase ? 0.0 : math.exp(-sinceBeat * 50);
|
|
final value =
|
|
0.75 * transient * math.sin(time * 2 * math.pi * 100) +
|
|
(random.nextDouble() - 0.5) * 0.003;
|
|
bytes.setInt16(i * 2, (value * 32767).round(), Endian.little);
|
|
}
|
|
return bytes.buffer.asUint8List();
|
|
}
|
|
|
|
void main() {
|
|
for (final bpm in [90.0, 120.0, 124.0, 160.0]) {
|
|
test('detects $bpm BPM and beat phase from audio', () {
|
|
final grid = analyzeAutoMixPcm(drumPattern(bpm));
|
|
expect(grid.reliable, isTrue, reason: '${grid.bpm}, ${grid.confidence}');
|
|
expect(grid.bpm, closeTo(bpm, 0.75));
|
|
expect(grid.beatAtOrAfter(0), closeTo(0.17, 0.04));
|
|
});
|
|
}
|
|
|
|
test('silence and unstructured noise do not invent a reliable beat', () {
|
|
expect(analyzeAutoMixPcm(Uint8List(11025 * 24 * 2)).reliable, isFalse);
|
|
final random = math.Random(11);
|
|
final noise = ByteData(11025 * 24 * 2);
|
|
for (var i = 0; i < noise.lengthInBytes; i += 2) {
|
|
noise.setInt16(i, random.nextInt(32000) - 16000, Endian.little);
|
|
}
|
|
expect(analyzeAutoMixPcm(noise.buffer.asUint8List()).reliable, isFalse);
|
|
});
|
|
|
|
AutoMixBeatGrid grid(double bpm, {double confidence = 0.9}) =>
|
|
AutoMixBeatGrid(
|
|
bpm: bpm,
|
|
phase: 0.17,
|
|
confidence: confidence,
|
|
firstSound: 0.1,
|
|
);
|
|
|
|
AutoMixPlan plan(double from, double to, {double confidence = 0.9}) =>
|
|
AutoMixPlan.create(
|
|
outgoingDuration: const Duration(seconds: 180),
|
|
incomingDuration: const Duration(seconds: 200),
|
|
outro: grid(from, confidence: confidence),
|
|
intro: grid(to),
|
|
outroOffset: 156,
|
|
)!;
|
|
|
|
test('compatible tempi align outgoing and incoming beats', () {
|
|
final transition = plan(120, 124);
|
|
expect(transition.beatMatched, isTrue);
|
|
expect(transition.fallbackReason, isNull);
|
|
expect(transition.rate, closeTo(120 / 124, 0.00001));
|
|
final beat = (transition.start.inMicroseconds / 1e6 - 156.17) / 0.5;
|
|
expect(beat, closeTo(beat.round(), 0.00001));
|
|
expect(transition.incomingStart.inMilliseconds, 170);
|
|
expect(
|
|
transition.start + transition.duration,
|
|
lessThanOrEqualTo(const Duration(seconds: 180)),
|
|
);
|
|
});
|
|
|
|
test('crossfade diagnostics distinguish each fallback condition', () {
|
|
expect(
|
|
plan(120, 160).fallbackReason,
|
|
AutoMixFallbackReason.incompatibleTempo,
|
|
);
|
|
expect(
|
|
plan(120, 124, confidence: 0.2).fallbackReason,
|
|
AutoMixFallbackReason.unreliableBeats,
|
|
);
|
|
AutoMixPlan? transition({
|
|
AutoMixBeatGrid? intro,
|
|
AutoMixOptions options = const AutoMixOptions(),
|
|
}) => AutoMixPlan.create(
|
|
outgoingDuration: const Duration(minutes: 3),
|
|
incomingDuration: const Duration(minutes: 3),
|
|
outro: grid(120),
|
|
intro: intro,
|
|
options: options,
|
|
);
|
|
expect(
|
|
transition()!.fallbackReason,
|
|
AutoMixFallbackReason.analysisUnavailable,
|
|
);
|
|
expect(
|
|
transition(
|
|
options: const AutoMixOptions(effect: AutoMixEffect.crossfade),
|
|
)!.fallbackReason,
|
|
AutoMixFallbackReason.crossfadePreset,
|
|
);
|
|
final late = transition(
|
|
intro: const AutoMixBeatGrid(
|
|
bpm: 120,
|
|
phase: 0.17,
|
|
confidence: 0.9,
|
|
firstSound: 4,
|
|
),
|
|
)!;
|
|
expect(late.fallbackReason, AutoMixFallbackReason.longIntro);
|
|
expect(late.beatMatched, isFalse);
|
|
expect(late.incomingStart, Duration.zero);
|
|
});
|
|
|
|
test('incompatible or uncertain beats use unstretched crossfade', () {
|
|
for (final transition in [
|
|
plan(120, 160),
|
|
plan(120, 124, confidence: 0.2),
|
|
]) {
|
|
expect(transition.beatMatched, isFalse);
|
|
expect(transition.rate, 1);
|
|
expect(transition.incomingStart, Duration.zero);
|
|
}
|
|
expect(
|
|
AutoMixPlan.create(
|
|
outgoingDuration: const Duration(seconds: 10),
|
|
incomingDuration: const Duration(minutes: 2),
|
|
),
|
|
isNull,
|
|
);
|
|
});
|
|
|
|
test('equal-power overlap restores normal tempo smoothly', () {
|
|
final transition = plan(120, 124);
|
|
final start = autoMixEnvelope(Duration.zero, transition);
|
|
expect(start.outgoing, 1);
|
|
expect(start.incoming, 0);
|
|
final middle = autoMixEnvelope(transition.duration ~/ 2, transition);
|
|
expect(
|
|
middle.outgoing * middle.outgoing + middle.incoming * middle.incoming,
|
|
closeTo(1, 0.00001),
|
|
);
|
|
expect(middle.rate, transition.rate);
|
|
final recovery = autoMixEnvelope(
|
|
transition.duration + const Duration(seconds: 4),
|
|
transition,
|
|
);
|
|
expect(recovery.rate, closeTo((transition.rate + 1) / 2, 0.00001));
|
|
final end = autoMixEnvelope(
|
|
transition.duration + const Duration(seconds: 8),
|
|
transition,
|
|
);
|
|
expect(end.complete, isTrue);
|
|
expect(end.rate, 1);
|
|
expect(end.incoming, 1);
|
|
expect(end.outgoing, closeTo(0, 0.00001));
|
|
});
|
|
|
|
test('AutoMix is off for new and existing settings, and persists opt-in', () {
|
|
expect(const AppSettings().autoMix, isFalse);
|
|
expect(AppSettings.fromJson({}).autoMix, isFalse);
|
|
final enabled = const AppSettings().copyWith(autoMix: true);
|
|
expect(AppSettings.fromJson(enabled.toJson()).autoMix, isTrue);
|
|
expect(enabled.copyWith(autoMix: false).autoMix, isFalse);
|
|
});
|
|
}
|