mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add LPCNet voice mode support
This commit is contained in:
21
lib/services/voice_codec_preferences.dart
Normal file
21
lib/services/voice_codec_preferences.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
|
||||
class VoiceCodecPreferences {
|
||||
static const String _codecKey = 'voice_codec';
|
||||
static const VoiceCodecKind defaultCodec = VoiceCodecKind.codec2;
|
||||
|
||||
static Future<VoiceCodecKind> getCodec() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_codecKey);
|
||||
return VoiceCodecKind.values.firstWhere(
|
||||
(codec) => codec.name == raw,
|
||||
orElse: () => defaultCodec,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> setCodec(VoiceCodecKind codec) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_codecKey, codec.name);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,27 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:codec2_flutter/codec2_flutter.dart';
|
||||
import 'package:lpcnet_flutter/lpcnet_flutter.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
|
||||
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
|
||||
|
||||
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
|
||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||
switch (pktMode) {
|
||||
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
|
||||
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
|
||||
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
|
||||
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
||||
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
||||
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
||||
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
|
||||
case VoicePacketMode.mode3200:
|
||||
return Codec2Mode.mode3200;
|
||||
case VoicePacketMode.mode1600:
|
||||
return Codec2Mode.mode1600;
|
||||
case VoicePacketMode.mode1400:
|
||||
return Codec2Mode.mode1400;
|
||||
case VoicePacketMode.mode700c:
|
||||
return Codec2Mode.mode700c;
|
||||
case VoicePacketMode.mode1200:
|
||||
return Codec2Mode.mode1200;
|
||||
case VoicePacketMode.mode1300:
|
||||
return Codec2Mode.mode1300;
|
||||
case VoicePacketMode.mode2400:
|
||||
return Codec2Mode.mode2400;
|
||||
case VoicePacketMode.lpcnet1600:
|
||||
throw ArgumentError('LPCNet mode does not map to Codec2');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,17 +46,24 @@ class VoiceCodecService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
|
||||
/// Returns the raw Codec2-encoded bytes.
|
||||
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
|
||||
_ensureCodec2Supported();
|
||||
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
|
||||
switch (mode.codec) {
|
||||
case VoiceCodecKind.codec2:
|
||||
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
|
||||
case VoiceCodecKind.lpcnet:
|
||||
return LpcNet.encodeInIsolate(pcm);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
|
||||
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
|
||||
_ensureCodec2Supported();
|
||||
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
|
||||
switch (mode.codec) {
|
||||
case VoiceCodecKind.codec2:
|
||||
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
|
||||
case VoiceCodecKind.lpcnet:
|
||||
return LpcNet.decodeInIsolate(codec2Bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode and concatenate multiple [packets] into a single PCM Int16List.
|
||||
@@ -59,24 +73,14 @@ class VoiceCodecService {
|
||||
VoicePacketMode mode,
|
||||
) async {
|
||||
_ensureCodec2Supported();
|
||||
final c2Mode = codec2ModeFor(mode);
|
||||
final c2 = Codec2.create(c2Mode);
|
||||
final spf = c2.samplesPerFrame;
|
||||
c2.destroy();
|
||||
|
||||
// Estimate total samples (use actual data or silence per missing packet)
|
||||
final all = <Int16List>[];
|
||||
for (final pkt in packets) {
|
||||
if (pkt == null || pkt.codec2Data.isEmpty) {
|
||||
// Silence for missing packet — duration approximated by mode
|
||||
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
|
||||
all.add(Int16List(silenceSamples));
|
||||
all.add(Int16List(mode.samplesPerPacket));
|
||||
} else {
|
||||
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode);
|
||||
all.add(decoded);
|
||||
all.add(await decode(pkt.codec2Data, mode));
|
||||
}
|
||||
}
|
||||
|
||||
final total = all.fold<int>(0, (sum, l) => sum + l.length);
|
||||
final result = Int16List(total);
|
||||
var offset = 0;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
|
||||
/// Web/unsupported platform stub — Codec2 FFI is not available.
|
||||
enum Codec2Mode {
|
||||
mode3200(0),
|
||||
mode2400(1),
|
||||
@@ -19,13 +18,20 @@ enum Codec2Mode {
|
||||
|
||||
int get bytesPerSecond {
|
||||
switch (this) {
|
||||
case mode3200: return 400;
|
||||
case mode700c: return 100;
|
||||
case mode1200: return 150;
|
||||
case mode1300: return 175;
|
||||
case mode1400: return 175;
|
||||
case mode1600: return 200;
|
||||
case mode2400: return 300;
|
||||
case mode3200:
|
||||
return 400;
|
||||
case mode700c:
|
||||
return 100;
|
||||
case mode1200:
|
||||
return 150;
|
||||
case mode1300:
|
||||
return 175;
|
||||
case mode1400:
|
||||
return 175;
|
||||
case mode1600:
|
||||
return 200;
|
||||
case mode2400:
|
||||
return 300;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,13 +46,22 @@ enum Codec2Mode {
|
||||
|
||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||
switch (pktMode) {
|
||||
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
|
||||
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
|
||||
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
|
||||
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
||||
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
||||
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
||||
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
|
||||
case VoicePacketMode.mode3200:
|
||||
return Codec2Mode.mode3200;
|
||||
case VoicePacketMode.mode1600:
|
||||
return Codec2Mode.mode1600;
|
||||
case VoicePacketMode.mode1400:
|
||||
return Codec2Mode.mode1400;
|
||||
case VoicePacketMode.mode700c:
|
||||
return Codec2Mode.mode700c;
|
||||
case VoicePacketMode.mode1200:
|
||||
return Codec2Mode.mode1200;
|
||||
case VoicePacketMode.mode1300:
|
||||
return Codec2Mode.mode1300;
|
||||
case VoicePacketMode.mode2400:
|
||||
return Codec2Mode.mode2400;
|
||||
case VoicePacketMode.lpcnet1600:
|
||||
throw ArgumentError('LPCNet mode does not map to Codec2');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +81,5 @@ class VoiceCodecService {
|
||||
Future<Int16List> decodePackets(
|
||||
List<VoicePacket?> packets,
|
||||
VoicePacketMode mode,
|
||||
) =>
|
||||
Future.error(UnsupportedError('Voice not supported on web'));
|
||||
) => Future.error(UnsupportedError('Voice not supported on web'));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Plays decoded 8000 Hz / 16-bit mono PCM samples by writing a WAV file
|
||||
/// Plays decoded mono PCM samples by writing a WAV file
|
||||
/// to the system temp directory and using [AudioPlayer].
|
||||
class VoicePlayerService {
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
@@ -49,16 +49,17 @@ class VoicePlayerService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Play [pcmSamples] (Int16, 8000 Hz, mono).
|
||||
Future<void> play(Int16List pcmSamples) async {
|
||||
Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
|
||||
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
|
||||
if (_isPlaying) await stop();
|
||||
_position = Duration.zero;
|
||||
_duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000);
|
||||
_duration = Duration(
|
||||
milliseconds: (pcmSamples.length * 1000) ~/ sampleRateHz,
|
||||
);
|
||||
_playbackStartedAt = DateTime.now();
|
||||
_events.add(null);
|
||||
|
||||
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000);
|
||||
final wavBytes = _buildWav(pcmSamples, sampleRate: sampleRateHz);
|
||||
final tmpDir = await getTemporaryDirectory();
|
||||
final file = File('${tmpDir.path}/vc_voice.wav');
|
||||
await file.writeAsBytes(wavBytes);
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
/// Captures raw PCM audio at 8000 Hz, 16-bit mono.
|
||||
/// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono.
|
||||
///
|
||||
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
|
||||
/// samples every [chunkDuration]. Call [stopCapture] to end recording.
|
||||
@@ -27,9 +27,10 @@ class VoiceRecorderService {
|
||||
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
|
||||
/// [enableCompressor] normalizes speech dynamics before encoding.
|
||||
/// [enableLimiter] protects against clipping peaks before encoding.
|
||||
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding.
|
||||
/// The returned stream emits [Int16List] chunks that are ready for voice encoding.
|
||||
Stream<Int16List> startCapture({
|
||||
Duration chunkDuration = const Duration(seconds: 1),
|
||||
int sampleRateHz = 8000,
|
||||
bool enableBandPassFilter = true,
|
||||
bool enableCompressor = true,
|
||||
bool enableLimiter = true,
|
||||
@@ -43,6 +44,7 @@ class VoiceRecorderService {
|
||||
|
||||
_startRecording(
|
||||
chunkDuration,
|
||||
sampleRateHz: sampleRateHz,
|
||||
enableBandPassFilter: enableBandPassFilter,
|
||||
enableCompressor: enableCompressor,
|
||||
enableLimiter: enableLimiter,
|
||||
@@ -52,13 +54,14 @@ class VoiceRecorderService {
|
||||
|
||||
Future<void> _startRecording(
|
||||
Duration chunkDuration, {
|
||||
required int sampleRateHz,
|
||||
required bool enableBandPassFilter,
|
||||
required bool enableCompressor,
|
||||
required bool enableLimiter,
|
||||
}) async {
|
||||
final config = const RecordConfig(
|
||||
final config = RecordConfig(
|
||||
encoder: AudioEncoder.pcm16bits,
|
||||
sampleRate: 8000,
|
||||
sampleRate: sampleRateHz,
|
||||
numChannels: 1,
|
||||
bitRate: 128000, // ignored for PCM, but required by API
|
||||
);
|
||||
@@ -66,16 +69,17 @@ class VoiceRecorderService {
|
||||
try {
|
||||
final stream = await _recorder.startStream(config);
|
||||
final voiceFilter = _VoiceBandPassFilter(
|
||||
sampleRate: 8000,
|
||||
sampleRate: sampleRateHz,
|
||||
lowCutHz: 250.0,
|
||||
highCutHz: 3400.0,
|
||||
);
|
||||
final dynamics = _VoiceDynamicsProcessor(
|
||||
sampleRate: 8000,
|
||||
sampleRate: sampleRateHz,
|
||||
enableCompressor: enableCompressor,
|
||||
enableLimiter: enableLimiter,
|
||||
);
|
||||
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
||||
final chunkBytes =
|
||||
sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
||||
final buffer = <int>[];
|
||||
|
||||
_sub = stream.listen(
|
||||
|
||||
Reference in New Issue
Block a user