Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
249 lines
7.2 KiB
Dart
249 lines
7.2 KiB
Dart
import 'dart:typed_data';
|
||
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:qlyra/core/media/ogg_page_writer.dart';
|
||
import 'package:qlyra/core/media/opus_ogg_index.dart';
|
||
|
||
const int _sampleRate = 48000;
|
||
const int _packetSamples = 960;
|
||
const int _preSkip = 312;
|
||
const int _serial = 0x4b6f6d74;
|
||
const int _packetsPerPage = 20;
|
||
|
||
Uint8List _le16(int value) =>
|
||
Uint8List(2)..buffer.asByteData().setUint16(0, value, Endian.little);
|
||
|
||
Uint8List _le32(int value) =>
|
||
Uint8List(4)..buffer.asByteData().setUint32(0, value, Endian.little);
|
||
|
||
Uint8List _opusHead() {
|
||
final out = BytesBuilder()
|
||
..add('OpusHead'.codeUnits)
|
||
..addByte(1)
|
||
..addByte(1)
|
||
..add(_le16(_preSkip))
|
||
..add(_le32(_sampleRate))
|
||
..add(_le16(0))
|
||
..addByte(0);
|
||
return out.toBytes();
|
||
}
|
||
|
||
Uint8List _opusTags() {
|
||
const vendor = 'qlyra-test';
|
||
final out = BytesBuilder()
|
||
..add('OpusTags'.codeUnits)
|
||
..add(_le32(vendor.length))
|
||
..add(vendor.codeUnits)
|
||
..add(_le32(0));
|
||
return out.toBytes();
|
||
}
|
||
|
||
Uint8List _audioPacket(int index) {
|
||
final out = Uint8List(40);
|
||
out[0] = 0x08;
|
||
out[1] = index & 0xff;
|
||
for (var i = 2; i < out.length; i++) {
|
||
out[i] = (index + i) & 0xff;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
Uint8List _buildStream(int packetCount, {int endTrim = 0}) {
|
||
final pages = <Uint8List>[];
|
||
var sequence = 0;
|
||
pages.add(
|
||
OggPageWriter.page(
|
||
headerType: OggPageWriter.beginningOfStream,
|
||
granulePos: 0,
|
||
serial: _serial,
|
||
sequence: sequence++,
|
||
packets: [_opusHead()],
|
||
),
|
||
);
|
||
pages.add(
|
||
OggPageWriter.page(
|
||
headerType: 0,
|
||
granulePos: 0,
|
||
serial: _serial,
|
||
sequence: sequence++,
|
||
packets: [_opusTags()],
|
||
),
|
||
);
|
||
|
||
for (var start = 0; start < packetCount; start += _packetsPerPage) {
|
||
final end = (start + _packetsPerPage).clamp(0, packetCount);
|
||
final last = end == packetCount;
|
||
final packets = [for (var i = start; i < end; i++) _audioPacket(i)];
|
||
pages.add(
|
||
OggPageWriter.page(
|
||
headerType: last ? OggPageWriter.endOfStream : 0,
|
||
granulePos: end * _packetSamples - (last ? endTrim : 0),
|
||
serial: _serial,
|
||
sequence: sequence++,
|
||
packets: packets,
|
||
),
|
||
);
|
||
}
|
||
|
||
final builder = BytesBuilder();
|
||
for (final page in pages) {
|
||
builder.add(page);
|
||
}
|
||
return builder.toBytes();
|
||
}
|
||
|
||
int _referenceCrc(Uint8List data) {
|
||
var crc = 0;
|
||
for (final byte in data) {
|
||
crc ^= (byte << 24) & 0xffffffff;
|
||
for (var bit = 0; bit < 8; bit++) {
|
||
if ((crc & 0x80000000) != 0) {
|
||
crc = ((crc << 1) ^ 0x04c11db7) & 0xffffffff;
|
||
} else {
|
||
crc = (crc << 1) & 0xffffffff;
|
||
}
|
||
}
|
||
}
|
||
return crc;
|
||
}
|
||
|
||
class _Page {
|
||
_Page({required this.headerType, required this.granulePos});
|
||
|
||
final int headerType;
|
||
final int granulePos;
|
||
}
|
||
|
||
List<_Page> _verifyPages(Uint8List bytes) {
|
||
final pages = <_Page>[];
|
||
var offset = 0;
|
||
while (offset + 27 <= bytes.length) {
|
||
expect(
|
||
String.fromCharCodes(bytes, offset, offset + 4),
|
||
'OggS',
|
||
reason: 'страница на смещении $offset',
|
||
);
|
||
final view = ByteData.sublistView(bytes, offset);
|
||
final segmentCount = bytes[offset + 26];
|
||
final tableStart = offset + 27;
|
||
var bodyLength = 0;
|
||
for (var i = 0; i < segmentCount; i++) {
|
||
bodyLength += bytes[tableStart + i];
|
||
}
|
||
final pageEnd = tableStart + segmentCount + bodyLength;
|
||
expect(pageEnd <= bytes.length, isTrue);
|
||
|
||
final stored = view.getUint32(22, Endian.little);
|
||
final page = Uint8List.fromList(bytes.sublist(offset, pageEnd));
|
||
ByteData.sublistView(page).setUint32(22, 0, Endian.little);
|
||
expect(_referenceCrc(page), stored, reason: 'CRC страницы $offset');
|
||
|
||
pages.add(
|
||
_Page(
|
||
headerType: bytes[offset + 5],
|
||
granulePos: view.getInt64(6, Endian.little),
|
||
),
|
||
);
|
||
offset = pageEnd;
|
||
}
|
||
expect(offset, bytes.length);
|
||
return pages;
|
||
}
|
||
|
||
void main() {
|
||
test('crc32 совпадает с побитовой реализацией на любой длине', () {
|
||
for (var length = 0; length <= 260; length++) {
|
||
final data = Uint8List.fromList(
|
||
List.generate(length, (i) => (i * 31 + length) & 0xff),
|
||
);
|
||
expect(
|
||
OggPageWriter.crc32(data),
|
||
_referenceCrc(data),
|
||
reason: 'длина $length',
|
||
);
|
||
}
|
||
});
|
||
|
||
test('crc32 по диапазону не зависит от окружающих байт', () {
|
||
final payload = Uint8List.fromList(
|
||
List.generate(1021, (i) => (i * 7) & 0xff),
|
||
);
|
||
final padded = Uint8List(payload.length + 9)
|
||
..fillRange(0, 5, 0xab)
|
||
..setRange(5, 5 + payload.length, payload)
|
||
..fillRange(5 + payload.length, payload.length + 9, 0xcd);
|
||
|
||
expect(
|
||
OggPageWriter.crc32(padded, 5, 5 + payload.length),
|
||
_referenceCrc(payload),
|
||
);
|
||
});
|
||
|
||
test('разбирает длительность из TOC-байтов', () {
|
||
final index = OpusOggIndex.parse(_buildStream(250))!;
|
||
expect(
|
||
index.duration,
|
||
closeTo((250 * _packetSamples - _preSkip) / _sampleRate, 1e-9),
|
||
);
|
||
});
|
||
|
||
test('не разбирает мусор', () {
|
||
expect(OpusOggIndex.parse(Uint8List(0)), isNull);
|
||
expect(OpusOggIndex.parse(Uint8List.fromList(List.filled(512, 7))), isNull);
|
||
});
|
||
|
||
test('срез укорачивает поток ровно на запрошенную позицию', () {
|
||
final index = OpusOggIndex.parse(_buildStream(500))!;
|
||
final total = index.duration;
|
||
|
||
for (final seconds in [0.02, 0.5, 1.0, 3.3, 7.75]) {
|
||
final sliced = index.sliceFrom(seconds);
|
||
expect(sliced, isNotNull, reason: 'срез с $seconds с');
|
||
final reparsed = OpusOggIndex.parse(sliced!)!;
|
||
expect(
|
||
reparsed.duration,
|
||
closeTo(total - seconds, 1e-6),
|
||
reason: 'длительность среза с $seconds с',
|
||
);
|
||
}
|
||
});
|
||
|
||
test('срез остаётся валидным Ogg с EOS на последней странице', () {
|
||
final index = OpusOggIndex.parse(_buildStream(500))!;
|
||
final sliced = index.sliceFrom(4.0)!;
|
||
final pages = _verifyPages(sliced);
|
||
|
||
expect(pages.length, greaterThan(2));
|
||
expect(pages.first.headerType & OggPageWriter.beginningOfStream, isNot(0));
|
||
expect(pages.first.granulePos, 0);
|
||
expect(pages[1].granulePos, 0);
|
||
expect(pages.last.headerType & OggPageWriter.endOfStream, isNot(0));
|
||
|
||
var previous = -1;
|
||
for (final page in pages) {
|
||
expect(page.granulePos, greaterThanOrEqualTo(previous));
|
||
previous = page.granulePos;
|
||
}
|
||
});
|
||
|
||
test('учитывает обрезку хвоста в финальной granule', () {
|
||
const trim = 700;
|
||
final index = OpusOggIndex.parse(_buildStream(300, endTrim: trim))!;
|
||
expect(
|
||
index.duration,
|
||
closeTo((300 * _packetSamples - _preSkip - trim) / _sampleRate, 1e-9),
|
||
);
|
||
|
||
final sliced = index.sliceFrom(2.0);
|
||
final reparsed = OpusOggIndex.parse(sliced!)!;
|
||
expect(reparsed.duration, closeTo(index.duration - 2.0, 1e-6));
|
||
});
|
||
|
||
test('срез за пределами длительности не строится', () {
|
||
final index = OpusOggIndex.parse(_buildStream(100))!;
|
||
expect(index.sliceFrom(0), isNull);
|
||
expect(index.sliceFrom(-1), isNull);
|
||
expect(index.sliceFrom(index.duration + 1), isNull);
|
||
});
|
||
}
|