Всем привет! Я Виктор Загускин, руководитель отдела голосового ML в MWS AI. Мы разрабатываем продукт формата «спичкит» — распознавание и синтез речи, анализ ее содержания. Наши клиенты используют эту технологию как кубики для создания прикладных продуктов. Чтобы лучше прочувствовать их потребности и боли, лучше познакомиться с тем, как реализовать голосовые ассистенты на основе современных решений, я решил попробовать сделать подобный продукт самостоятельно. Это будет работающий на локальном устройстве голосовой ассистент со встроенной LLM.

В этом цикле материалов я буду рассказывать о процессе создания ассистента, примененных технологиях, выбранном железе, трудностях и путях их преодоления, буду  демонстрировать этапы работ. Попутно расскажу основные концепции, необходимые для реализации голосовых технологий. 

Для создания промышленного решения я бы, конечно, взял собственные продукты MWS AI. Но поскольку хочется рассказать об этой активности широко, я решил использовать открытые решения, доступные каждому, а не только корпоративному сегменту. Это даст дополнительную возможность обсудить критерии выбора голосовых технологий и тонкости, на которые стоит обратить внимание. 

Первая часть цикла посвящена базе — выбору «железа», тулкитов для инференса, моделей для синтеза и распознавания речи и LLM. Поехали!

Аппаратная платформа

Когда я изучал потенциальные аппаратные платформы для нашего продукта, мне попалось на глаза довольно любопытное семейство одноплатников Rockchip. Их старшие модели довольно производительны в плане обычных вычислений и подходят для запуска инференса нейросетей. Для последнего есть специальный NPU производительностью до 6TOPS, который может решать довольно широкий класс задач. При этом, компактный форм-фактор позволяет реализовывать на их основе нечто вроде голосовой колонки.

Альтернативой могут быть устройства семейства Jetson от Nvidia либо какие-то компактные сборки на базе x86 семейства. Но не могу сказать, чтобы какие-то из них существенно выигрывали у «рокчипов» в соотношении цены, производительности и доступности.

«Джетсоны» существенно мощнее и, скорее всего  экономичнее по энергии, но цена на них  в российских онлайн-магазинах за комплект разработки начинается от 80 тыс. рублей за младшие модели. x86 — дороже, больше, прожорливее. Поэтому довольно много edge AI-решений сейчас делаются именно на «рокчипах».

В таблице я сравнил разные варианты «железяк» по цене, доступности, производительности и энергоэффективности. Не благодарите☺

Платформа / Модель

Примерная цена (devkit / модуль)

Доступность (СНГ)

Доступность (США)

TDP (Вт)

ИИ-производительность (INT8)

Энергоэффективность (TOPS/Вт)

NVIDIA Jetson Orin Nano 8/16 GB

$800 – $1000

⚠️ Ограниченная

✅ Хорошая

10–15

40 TOPS

~2.7 – 4.0

NVIDIA Jetson Orin NX 16 GB

$800 – $1000

⚠️ Ограниченная

✅ Хорошая

15–25

70 TOPS

~2.8 – 4.7

NVIDIA Jetson AGX Orin 64 GB

$3,499 – $3,999

❌ Очень низкая

✅ Хорошая

20–60

275 TOPS

~4.6 – 5.5

Rockchip RK3588" (e.g. Orange Pi 5 Plus, Radxa ROCK 5B)"

$120 – $250

✅ Высокая

✅ Хорошая

8–12 (до 20 под пик)

6 TOPS

~0.5 – 0.75

Intel Core i3-N305" (Alder Lake-edge-ПК/NUC)"

$200 – $350 (мат. плата)

✅ Высокая

✅ Хорошая

7–15

~6–10 TOPS

~0.6 – 1.0

AMD Ryzen Embedded R2000 (e.g. R2314)

$250 – $400 (SBC)

✅ Средняя

✅ Хорошая

12–25

~8–12 TOPS

~0.5 – 1.0

Для эксперимента я выбрал одноплатный компьютер Nanopi M6 от компании FriendlyElec. Устройство поставляют собранным, в корпусе, и стоит оно в российских интернет-магазинах порядка 20 тыс. рублей (август 2025). На борту 8-ядерный ARM-процессор, 16Гб ОЗУ, видеокарта и NPU на 6TOPS. Последний, собственно, и позволяет «гонять» на нем довольно серьезные ML-модельки.

У моего устройства нет WiFi и встроенной карты eMMC. Есть порт LAN, HDMI, 3 USB, jack 3.5 и питание Type-C и слот для MicroSD карты. 

Amazon.com: FriendlyElec NanoPi M6 Computer Mini Router OpenWRT, LPDDR5  4GB/16GB/32GB RAM, 6Tops NPU Mali-G610 GPU RK3588S SoC, for NAS, Smart  Gateway and 8K Video Playing, Support Debian Ubuntu (4GB Metal Case) :
Amazon.com: FriendlyElec NanoPi M6 Computer Mini Router OpenWRT, LPDDR5 4GB/16GB/32GB RAM, 6Tops NPU Mali-G610 GPU RK3588S SoC, for NAS, Smart Gateway and 8K Video Playing, Support Debian Ubuntu (4GB Metal Case) :

Подключение и установка ОС

Для комфортной работы на устройстве нужны:

  1. Кабель LAN.

  2. MicroSD карта хорошего качества (после двух неудачных попыток на Kingston и еще каком-то «ноунейм», заработало на Samsung EVO 64Gb).

  3. Кардридер SD с адаптером/MicroSD («ноунейм» за 200 рублей с «Озона» работает нормально).

  4. Программа Balena Etcher для прошивки флешки (бесплатно можно скачать под любую ОС).

  5. Компьютер/ноутбук для прошивки флешки.

  6. Опционально, но желательно: мышь, клава, монитор, колонки/наушники/микрофонная гарнитура, LAN-коммутатор.

Начало работы, в целом, простое: скачиваем образ ОС и прошиваем на карту с помощью Etcher. Ошибиться сложно: выбрали файл образа архивом, как скачали с сайта, выбрали карту, нажали кнопку «Прошить». Прошло 10 минут, дождались валидации. Видим  сообщение об успехе — извлекаем карту. Мак может ругаться, что не может карту прочитать — это нормально.

Лучше взять ubuntu как образ — там точно найдется/запустится весь нужный софт. Более экономичный FriendlyWRT от производителя обладает экзотическим C++ рантаймом, и там многое не получается сразу поставить.

Вставляем карту в слот устройства, втыкаем кабель монитора в слот HDMI и включаем питание. Можно и сеть сразу подключить. Мы должны почти сразу увидеть эмблемку  FriendlyElec и затем обычный вид десктопа ubuntu. Заходим юзером pi с паролем sudo pi.

Сеть должна сразу настроиться.

Если что-то идет не так — проверяем в первую очередь работоспособность SD-карты, адаптер питания и все соединительные кабели. Если подключить монитор, то в нормальном сценарии мы увидим обычный десктоп ubuntu.

Инференс ML-моделей на NPU Rockchip: RKNN и RKNN-LLM — что это, как работает и зачем нам нужны

Для того чтобы запускать нейросетевые модели на аппаратном ускорителе (NPU) в устройствах Rockchip, компания предоставляет собственный инструментарий — RKNN Toolkit (и его «брат» для LLM — RKNN-LLM). Эти библиотеки позволяют не просто запускать модели, а оптимизировать их под конкретную архитектуру NPU, используя квантование, слияние операций и другие трюки, чтобы получить максимальную производительность при минимальном энергопотреблении.

Что делают rknn и rknn-llm?

- RKNN — основной тулкит для конвертации и инференса классических моделей (CV/Speech/NLP). Он принимает модели из форматов ONNX, TensorFlow, PyTorch (через ONNX), и конвертирует их в .rknn — специальный бинарный формат, оптимизированный под NPU.

- RKNN-LLM — отдельный пакет, предназначенный для работы с большими языковыми моделями (LLM). Он обрабатывает модели в форматах GGUF, Hugging Face Transformers, ONNX, и также конвертирует их в .rknn, но с учётом особенностей работы с последовательностями, кэширования внимания и т.п.

Оба тулкита предоставляют API на Python и C++,  командные утилиты для конвертации (rknn-toolkit2, rknn-llm-toolkit) и запуска.

Как это работает на практике? Пример конвертации

Представьте, что вы хотите запустить модель Whisper-tiny для распознавания речи. Вы берете ее из Hugging Face, конвертируете в ONNX, затем через RKNN превращаете в .rknn.

from rknn.api import RKNN

rknn = RKNN()

rknn.config(mean_values=[[0, 0, 0]], std_values=[[1, 1, 1]])

rknn.load_onnx(model='whisper_tiny.onnx')

rknn.build(do_quantization=True, dataset='./dataset.txt')  # квантуем!

rknn.export_rknn('whisper_tiny.rknn')


Здесь:

- config() — задаём нормализацию входных данных (если нужно).

- load_onnx() — загружаем модель.

- build() — конвертируем и квантуем модель (обязательно указываем датасет для калибровки!).

- export_rknn() — сохраняем готовую модель.

Важно: не все модели конвертируются. Особенно проблемные те, что используют динамические формы (например, переменную длину текста или изображений), слои, которые еще не реализованы в RKNN (например, LayerNorm с нестандартными параметрами, GroupNorm, некоторые attention-слои). Они просто «падают» без понятной ошибки. Это частая боль при работе с RKNN.

Что делать, если конвертация не работает?

Решение — использовать rknn_model_zoo (https://github.com/airockchip/rknn_model_zoo).

Это официальный репозиторий, где Rockchip уже подготовил готовые модели и скрипты конвертации для самых популярных задач: объектное детектирование, сегментация, ASR, TTS и т.д.

Вы просто клонируете репозиторий, запускаете скрипт конвертации (если нужно), и сразу можете использовать модель в своём проекте.

cd python

python convert.py <onnx_model> <TARGET_PLATFORM> <dtype(optional)> <output_rknn_path(optional)>

например: 

python convert.py ../model/whisper_encoder_base_20s.onnx rk3588

python convert.py ../model/whisper_decoder_base_20s.onnx rk3588

Запуск инференса: Python и C++

В rknn_model_zoo есть примеры инференса на Python и C++. Для нашего ассистента мы будем использовать Python-скрипты — они проще для прототипирования.

Инференс через ONNX

python whisper.py --encoder_model_path <onnx_model> -decoder_model_path <onnx_model> --task <TASK> --audio_path <AUDIO_PATH>

например:

python whisper.py --encoder_model_path ../model/whisper_encoder_base_20s.onnx --decoder_model_path ../model/whisper_decoder_base_20s.onnx --task en --audio_path ../model/test_en.wav


Инференс через RKNN

python whisper.py --encoder_model_path <rknn_model> -decoder_model_path <rknn_model> --task <TASK> --audio_path <AUDIO_PATH> --target <TARGET_PLATFORM>

например

python whisper.py --encoder_model_path ../model/whisper_encoder_base_20s.rknn --decoder_model_path ../model/whisper_decoder_base_20s.rknn --task en --audio_path ../model/test_en.wav --target rk3588


Для использования NPU необходимо вызывать модели в .rknn формате, onnx – выполняется на CPU.

Выбор модели для синтеза речи

Из готовых моделей синтеза речи с поддержкой русского языка  в rknn_model_zoo есть только сконвертированная модель mms_tts на основе архитектуры VITS. Это довольно современная, легковесная flow-matching модель, которая дает неплохие интонации. Внутри тулкита модель англоязычная, но доступны необходимые скрипты для конвертации русскоязычного варианта (есть на Hugging Face).

В будущем можно попытаться сконвертировать что-то из более современных моделей, или более адаптированный к русскому языку вариант, но для первой версии голосового ассистента этой модели будет вполне достаточно — со своими задачами она справляется (если не считать легкого английского акцента и неумения озвучивать цифры).

Все довольно просто: в скрипте export_onnx.py меняем в model, tokenizer = setup_model ("facebook/mms-tts-eng") на “mms-tts-rus, запускаем python export-onnx.py 

и получаем onnx модельки для русского языка. 

Затем для каждой модельки (энкодер и декодер) вызываем

 convert.py mms_tts_eng_encoder_200.onnx rk3588 

и получаем сконвертированные файлы с расширением .rknn — это готовые к запуску образы моделей.

Теперь осталось «подшаманить» со скриптом запуска. В скрипте mms_tts.py заменим словарь токенизатора (vocab) на русский, пример текста тоже заменим на какой-то русский текст. Словарь для русского берем вот отсюда, для проверки работы выдумываем текст из 200 (не более) символов, или того количества символов, которое мы указали при конвертации. Но указывать много не нужно — длинный текст при синтезе стоит разбивать на предложения. 

После этого можно запускать скрипт, указав ему пути к .rknn моделям через encoder(decoder)-model-path опцию. Будет создан .wav файл озвученного текста, который можно послушать в наушниках, например через aplay ../output.wav.

Звучит средне, относительно чисто, интонации неплохие, но мы слышим акцент и некоторые звуки произносятся неестественно. Для продакшн-решения придется еще поработать — подыскать либо дообучить хорошую модель, решить вопросы с предобработкой текста, расстановкой ударений, раскрытием сокращений и чисел. Но для быстрой демки вполне подойдет.

Итого: нам нужна функция для проигрывания аудио на устройстве и код вызова моделей синтеза речи. Выглядит это вот так:

def play_audio(waveform, samplerate=16000):

    """

    Проигрывает аудио из numpy-массива через наушники.

    

    waveform: numpy.ndarray формы (n_samples,) или (n_samples, channels)

    samplerate: частота дискретизации (например, 16000)

    """

    # Нормализуем аудио в диапазон [-1, 1] (если не нормализовано)

    waveform = np.asarray(waveform, dtype=np.float32)

    if np.max(np.abs(waveform)) > 1.0:

        waveform = waveform / np.max(np.abs(waveform))


    print("? Проигрывание аудио...")

    print(waveform.shape, max(waveform), waveform.mean())

    sd.play(waveform, samplerate=samplerate, device=0)

    sd.wait()  # Ждём окончания проигрывания

    print("✅ Проигрывание завершено.")


class TTSVocaliser:

    def init(self):

        encoder_path = os.path.join(MODEL_DIR, ENCODER)

        decoder_path = os.path.join(MODEL_DIR, DECODER)

        self.encoder = self.init_rknn_model(encoder_path)

        self.decoder = self.init_rknn_model(decoder_path)


    def init_rknn_model(self, model_path):

        model = RKNN()

        ret = model.load_rknn(model_path)

        ret = model.init_runtime(target="rk3588", device_id=None)

        return model

    

    def vocalise(self, inp: str):

        input_ids_array, attention_mask_array = preprocess_input(inp, vocab, max_length=MAX_LENGTH)


        # Encode

        log_duration, input_padding_mask, prior_means, prior_log_variances = run_encoder(self.encoder, input_ids_array, attention_mask_array)


        # Middle process

        attn, output_padding_mask, predicted_lengths_max_real = middle_process(log_duration, input_padding_mask, MAX_LENGTH)


        # Decode

        waveform = run_decoder(self.decoder, attn, output_padding_mask, prior_means, prior_log_variances)

        #audio_save_path = "test_output.wav"

        #sf.write(file=audio_save_path, data=np.array(waveform[0][:predicted_lengths_max_real * 256]), samplerate=16000)

    

        audio_data=np.array(waveform[0][:predicted_lengths_max_real * 256])

        play_audio(audio_data)

Распознавание речи

Итак, теперь нам нужна модель распознавания речи, которая была бы:

  • легковесной;

  • русскоязычной;

  • без проблем конвертировалась на rknn;

  • умела распознавать в потоковом режиме.

Начнем с того, что уже присутствует в зоопарке — с Whisper. Модель от OpenAI, одна из лидеров по качеству распознавания на английском, есть разные размеры, от компактных, до огромной на 1.5B параметров.

Готовый скрипт есть для английского и китайского языков. Для русского придется поправить детокенизацию и промпт, что в принципе несложно.

Проблема в другом:

- Потокового распознавания нет, надо кидать целиком фразу длиной обычно 10 или 20 секунд (зависит от настроек конвертации модели), затем ждать, когда она целиком распознается, а это еще несколько секунд — для голосового ассистента, простите за слово, «неюзабельно».

- Хотя формальные метрики качества у Whisper очень неплохи, особенно у адаптированных версий (см. например https://alphacephei.com/nsh/2023/01/22/russian-models.html), на практике регулярно встречаются сюрпризы в виде текста, абсолютно не соответствующего произносимому. Модель может зациклиться (или не к месту вспомнить имя создателя субтитров из своей обучающей выборки). 

Второй вариант – модель Vosk (https://huggingface.co/alphacep/vosk-model-small-streaming-ru/tree/main). Она сделана на основе тулкита K2-FSA (https://github.com/k2-fsa), поэтому для нее доступены и код инференса, и скрипты конвертации. Сконвертированные модельки и короткую инструкцию по конвертации и запуску я положил сюда https://huggingface.co/vzaguskin/vosk_rknn.

Оказалось довольно неплохо — почти всегда качества расшифровки достаточно, чтобы намерение было понятно LLMке, плюс есть готовые скрипты потокового распознавания и детекции окончания фраз. Эти скрипты работают и на «рокчипе» — не нужно  реализовывать довольно сложный кусок логики. 

Итоговый код выглядит вот так:

MODEL_DIR = "models/vosk-asr"

import sherpa_onnx

def create_recognizer():

    # Создаём распознаватель напрямую через конструктор

    recognizer = sherpa_onnx.OnlineRecognizer.from_transducer(

        tokens = os.path.join(MODEL_DIR, "tokens.txt"),

        encoder = os.path.join(MODEL_DIR, "vosk-ru-encoder.rknn"),

        decoder = os.path.join(MODEL_DIR, "vosk-ru-decoder.rknn"),

        joiner = os.path.join(MODEL_DIR, "vosk-ru-joiner.rknn"),

        num_threads=4,

        sample_rate=16000,

        feature_dim=80,

        enable_endpoint_detection=True,  # ← Включаем детекцию конца фразы!

        rule1_min_trailing_silence=1.0,  # 1 сек тишины = конец фразы

        rule2_min_trailing_silence=0.8,

        rule3_min_utterance_length=3.0,  # мин. длина фразы 3 сек

        provider="rknn",  # ← важно для Rockchip!

        model_type="zipformer",

        debug=False,

    )

    return recognizer

def listen_and_recognize_phrase(device=0, sample_rate=16000, timeout=15.0):

    recognizer = create_recognizer()

    stream = recognizer.create_stream()

    print("?️ Говорите... (ждём конца фразы, таймаут 15 сек)")

    start_time = time.time()

    def callback(indata, frames, time_info, status):

        if status:

            print(status)

        samples = indata[:, 0]

        stream.accept_waveform(sample_rate, samples)


    with sd.InputStream(

        device=device,

        channels=1,

        samplerate=sample_rate,

        dtype='float32',

        callback=callback,

        blocksize=4000

    ):

        while time.time() - start_time < timeout:

            while recognizer.is_ready(stream):

                recognizer.decode_stream(stream)

            if recognizer.is_endpoint(stream):

                result = recognizer.get_result(stream).strip()

                if result:

                    print(f"✅ Распознано: {result}")

                    return result

                recognizer.reset(stream)

            time.sleep(0.01)

    print("⏰ Таймаут — ничего не распознано.")

    return ""

Voice Activity Detector

Если мы делаем полноценного ассистента, который постоянно слушает микрофон, еще нужен детектор голосовой активности, чтобы модели распознавания включались только тогда, когда слышат реальную речь, и так экономили энергию устройства. Дополнительно обычно реализуют реакцию на ключевое слово (Алиса, Siri, и т.п.). Это мы перенесем на следующий этап. Пока будем вручную запускать демку, когда захотим пообщаться с моделью .

Выбор и запуск LLM

Из коробки в RKNN-LLM доступны модели Qwen разных размеров, от 0.6 до 4B, сконвертированные в Int8. Модель 4B получается запустить отдельно, несмотря на то, что по документации на устройстве всего 2 Gb NPU-памяти. Но вместе с распознаванием и синтезом она уже не влазит.

Инструментов профилирования не хватает. Я не нашел, как посмотреть физически доступный и занятый объем NPU памяти. Для rknn-моделей можно из python api посмотреть в отладочных логах, сколько они занимают, но для rknn-llm нет ни питоновского API, ни аналогичных логов. Возможно, я не там искал. 

Модель размером 1.7B откликается достаточно живо и демонстрирует больше признаков интеллекта, чем 0.6. Последняя летает, но совсем простенькая. Есть, конечно, вариант перед каждой генерацией выгружать из памяти другие модели и загружать туда LLMку, а для работы с голосом опять выгружать. Но в сочетании с медленной скоростью генерации самой модели это будет (и снова простите) «неюзабельно».

В RKNN-LLM есть готовый пример запуска Flask-сервера с выбранной моделью, и клиент для этого API.

Поэтому пока я остановился на варианте 1.7B.

Код запроса к Flask-серверу в простейшем случае выглядит так:

def send_chat_request(user_message, is_streaming=True, voice_callback = None):

    print("Поступил запрос:", user_message)

    headers = {

                    'Content-Type': 'application/json',

                    'Authorization': 'not_required'

                }

    data = {

                    "model": MODEL,

                    "messages": [{"role": "user", "content":  user_message}],

                    "stream": is_streaming,

                    "enable_thinking": False,

                    "tools": None

                }

    print("Data:", data)

    # Send a POST request

    responses = session.post(server_url, json=data, headers=headers, stream=is_streaming, verify=False)


    if not is_streaming:

        # Parse the response

        if responses.status_code == 200:

            print("Q:", data["messages"][-1]["content"])

            reply = json.loads(responses.text)["choices"][-1]["message"]["content"]

            print("A:", reply)

            if voice_callback:

                voice_callback(reply)

        else:

            print("Error:", responses.text)

    else:

        if responses.status_code == 200:

            print("Q:", data["messages"][-1]["content"])

            print("A:", end="")

            buff = ""

            for line in responses.iter_lines():

                if line:

                    line = json.loads(line.decode('utf-8'))

                    if line["choices"][-1]["finish_reason"] != "stop":

                        reply = line["choices"][-1]["delta"]["content"]

                        #print("reply:", reply, end="")

                        buff += reply

                        sys.stdout.flush()

                        if len(buff) > 200 or buff[-1] in ['.', '?', '!']:

                            print("озвучиваю:", buff)

                            if voice_callback:

                                voice_callback(buff)

                            

                            buff = ""

            if buff and voice_callback:

                voice_callback(buff)

                print("озвучиваю:", buff)

                        

                            

        else:

            print('Error:', responses.text)

Hello world, то есть голосовой чат-бот

Ну что ж, теперь есть все, чтобы собрать минимально работающего на локальном устройстве голосового чат-бота. Работать он будет так:

  1. Приветствуем пользователя голосовым сообщением.

  2. Слушаем его реплику с таймаутом 15 секунд.

  3. Отправляем реплику в LLM-сервер.

  4. В потоковом режиме получаем ответ.

  5. После накопления достаточного количества текста (200 символов), либо концу предложения, осуществляем синтез и озвучиваем. Повторяем, пока приходит ответ.

  6. Снова слушаем реплику пользователя.

На данном этапе сделаем это в виде очень простого цикла, вызывающего реализованные выше функции.

voc = TTSVocaliser()

def run_agent():

    say_message(WELCOME_MESSAGE)

    while True:

        inp = listen_and_recognize_phrase()

        send_chat_request(inp, voc.vocalise)

Итог первого этапа

Итак, мы получили полностью локального голосового ассистента на одноплатном компьютере с применением большой (относительно) языковой модели. 

Что нам не хватает для счастья:

  1. Качественного синтеза речи, способного в том числе озвучивать числа и латинские символы, правильно ставить ударения.

  2. Параллельного выполнения долгих операций: ожидание следующей части ответа LLM, озвучивание предыдущей и ее проигрывание — может происходить одновременно.

  3. Эхоподавления и возможности слушать и говорить одновременно.

  4. Истории разговоров и какой-то устойчивой личности самого ассистента.

  5. Способности выполнять какую-то полезную работу: искать информацию, управлять списками задач, смотреть календарь.

  6. Асинхронного взаимодействия — способности отправлять сообщения в Telegram или email.

  7. Возможности подключать действительно мощную модельку (даже не локальную) для каких-то серьезных задач.

  8. Внешнего хранилища для постоянных данных.

Решению этих вопросов мы посвятим следующие публикации, stay tuned! 

Более детальную реализацию проекта можно посмотреть на GitHub, а о новостях и обновлениях узнавать в Telegram.

На десерт — Здесь можно посмотреть демонстрацию работы.

В статье упоминаются опенсорсные технологические решения компании Meta Platform, признанной экстремистской и запрещенной в РФ.

Комментарии (1)


  1. JBFW
    27.11.2025 10:02

    MicroSD карта хорошего качества (после двух неудачных попыток на Kingston и еще каком-то «ноунейм», заработало на Samsung EVO 64Gb)

    Вам просто повезло купить палёнку )
    Их подделывают, как раз Kingston часто: контроллер карты говорит про 64 или 120Гб, по факту там циклическая запись 512Мб или меньше. Мелкие файлы "для проверки" работают, а потом одни молча перезаписывают другие.

    Так-то Kingston хорошие карты делает, и "ноунеймы" - если настоящие...

    Ubuntu тут лучше на Debian заменить, имхо - более свежий набор пакетов в репозитарии, нет "корпоративных заморочек" - меньше лишнего, при этом технически одно и то же с одинаковыми пакетными менеджерами.

    Внешнего хранилища для постоянных данных

    USB SSD drive, nfs share, iscsi drive