Дисклеймер. Материал подготовлен исключительно в образовательных и исследовательских целях. Автор не связан с группой «Руки Вверх» и не планирует получать прибыль от её творчества. Упоминания произведений и отдельные фрагменты используются только для технического анализа в рамках применимых ограничений авторского права, включая право цитирования. Материал не является инструкцией или призывом к нарушению авторских прав.

От RNN до Qwen и Suno: как я бы в 2026 году повторил эксперимент с генерацией песни


В 2019 году я уже проводил похожий эксперимент.


Я собрал тексты песен группы «Руки Вверх», подготовил небольшой датасет, обучил на нём рекуррентную нейронную сеть и попросил её писать новые тексты.


ML-часть проекта могла выглядеть примерно так:


from textgenrnn import textgenrnn

textgen = textgenrnn()

textgen.train_from_file(
    "trans.csv",
    num_epochs=1,
)

textgen.generate(
    3,
    temperature=1.0,
)

По меркам 2019 года это был вполне нормальный учебный проект:


песни
 ↓
датасет
 ↓
RNN
 ↓
новый текст

Прошло семь лет.


Сегодня повторять тот же эксперимент буквально было бы не очень интересно.


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


Поэтому вопрос теперь другой.


Не:


Может ли нейросеть написать песню?

А:


Что сегодня можно сделать с небольшим специализированным корпусом, если сама языковая модель уже умеет почти всё?

И второй, более практический вопрос:


Можно ли пройти весь путь от старого набора текстов до действительно готовой композиции с музыкой и вокалом?

Именно это я и попробую собрать.



Сначала разберёмся, что вообще строим


Представим, что у нас есть небольшой корпус песен.


Например, сотня текстов.


В 2019 году эти данные почти целиком уходили на обучение модели.


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


Во-первых, слегка адаптировать готовую языковую модель.


Для этого понадобится LoRA.


Во-вторых, при генерации новой песни находить в корпусе не случайные, а подходящие по смыслу примеры.


Для этого понадобятся embeddings и FAISS.


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


И наконец, готовый текст можно передать в Suno, чтобы получить уже не текстовый файл, а музыкальную композицию.


Вся схема выглядит так:


                     КОРПУС ПЕСЕН
                    /             \
                   ▼               ▼
                LoRA          embeddings
                  │                │
                  │              FAISS
                  │                │
                  │        подходящие примеры
                  │                │
                  └───────┬────────┘
                          ▼
                      Qwen + LoRA
                          │
                          ▼
                     новый текст
                          │
                          ▼
                  контроль сходства
                          │
                          ▼
                  описание звучания
                          │
                          ▼
                         Suno
                          │
                          ▼
                  готовая композиция

На первый взгляд получилось намного сложнее старой RNN.


Но здесь нет компонентов «ради архитектуры».


Каждый появляется только потому, что решает отдельную проблему.


Компонент Что делает
Qwen уже умеет писать нормальный русский текст
LoRA адаптирует модель под небольшой корпус
embeddings превращают смысл текста в числовое представление
FAISS находит похожие по смыслу фрагменты
prompt с примерами задаёт локальный ориентир конкретной генерации
контроль сходства обнаруживает подозрительные совпадения
LangSmith помогает понять, что произошло внутри pipeline
Suno превращает текст в музыку и вокал

Теперь можно идти по порядку.


Почему я больше не стал бы обучать модель с нуля


Старая логика:


100 песен
 ↓
пытаемся научить модель писать

Современная:


готовая языковая модель
          +
     100 песен
          ↓
       адаптация

Разница принципиальная.


Qwen уже понимает:


  • русский язык;
  • грамматику;
  • структуру текста;
  • инструкции;
  • стихи и песни;
  • длинный контекст.

Наш небольшой датасет не должен учить модель языку заново.


Он нужен только для небольшого сдвига поведения.


И здесь появляется LoRA.


LoRA: меняем не модель целиком, а её поведение


Полное дообучение языковой модели означает изменение огромного количества параметров.


Для сотни песен это выглядит как стрельба из пушки по воробьям.


LoRA работает иначе.


Основная модель остаётся практически неизменной:


Qwen

├── исходные веса
├── LoRA ← обучаем
├── исходные веса
├── LoRA ← обучаем
└── исходные веса

Мы добавляем относительно небольшие обучаемые адаптеры.


Это намного дешевле и удобнее для эксперимента.


Но здесь есть важный момент.


LoRA отвечает на вопрос:


Изменилось ли общее поведение модели после знакомства с нашим корпусом?

Она не отвечает на другой вопрос:


Какие конкретные песни из корпуса лучше всего подходят под тему текущего запроса?

Для этого нужен отдельный механизм.


Зачем модели вообще показывать примеры


Представим запрос:


Ночная Москва после дождя,
случайная встреча бывших,
ностальгия,
последнее метро.

Допустим, у нас есть сто песен.


Можно взять четыре случайные:


лето
море
школа
вечеринка

Формально это тоже наш корпус.


Но для текущего запроса такие примеры почти бесполезны.


Гораздо интереснее найти фрагменты про:


ночь
город
расставание
встречу
ностальгию

То есть нужен поиск по смыслу, а не просто по совпадению слов.


Отсюда появляется следующий элемент системы — embeddings.


Что такое embeddings без магии


Embedding — это способ представить текст набором чисел.


Условно:


"ночная Москва после дождя"

↓

[0.12, -0.44, 0.91, ..., 0.17]

Другой текст:


"мы встретились ночью у метро"

↓

[0.10, -0.39, 0.87, ..., 0.20]

Сами числа человеку ничего не говорят.


Главное в другом: тексты с похожим смыслом получают близкие векторы.


Поэтому модель поиска уже может понять, что:


"ночная Москва"

и:


"Москва ночью"

намного ближе друг к другу, чем к:


"летний день на море"

Для эксперимента возьмём многоязычную embedding-модель:


intfloat/multilingual-e5-base

Теперь у нас появляется следующая проблема.


Допустим, мы превратили сотни фрагментов песен в векторы.


Как среди них быстро найти ближайшие?


Вот теперь — и только теперь — нам нужен FAISS.


Что такое FAISS и почему он здесь вообще появился


FAISS не генерирует текст.


FAISS не обучает Qwen.


FAISS не является LLM.


Его задача очень простая:


у нас есть много векторов
 ↓
приходит новый вектор запроса
 ↓
найти ближайшие

Например:


тема пользователя
 ↓
embedding
 ↓
FAISS
 ↓
20 ближайших фрагментов
 ↓
выбираем 4 лучших

Для небольшого локального эксперимента этого более чем достаточно.


Если когда-нибудь система превратится в полноценный сервис с фильтрами и постоянным хранилищем, FAISS можно заменить, например, на Qdrant.


Но начинать сразу с отдельной векторной БД здесь просто незачем.


Кириллицу больше не транслитерируем


В старом эксперименте я переводил русский текст в латиницу:


Я тебя люблю
 ↓
Ya tebya lyublyu

Сегодня это уже исторический артефакт.


Современный токенизатор нормально работает с кириллицей:


tokens = tokenizer(
    "Я тебя люблю"
)

Поэтому сохраняем:


  • кириллицу;
  • пунктуацию;
  • переносы строк;
  • букву ё;
  • структуру строф.

Для песни последние два пункта особенно важны.


Если превратить текст песни в один длинный абзац, часть структуры просто потеряется.


Какое железо понадобится


В качестве базовой модели возьмём:


Qwen/Qwen3-1.7B

Это сравнительно небольшая модель, поэтому эксперимент ещё можно проводить локально.


Ориентировочно:


Режим Видеопамять ОЗУ
генерация, 4-bit 4–6 ГБ 8–16 ГБ
LoRA 10–16 ГБ 16+ ГБ
QLoRA, 4-bit 6–10 ГБ 16+ ГБ
длинные последовательности 16–24 ГБ 32+ ГБ

Для карты с 8–12 ГБ VRAM разумнее использовать QLoRA.


Например:


RTX 3060 12 GB
RTX 4060 Ti 16 GB
RTX 4070 12 GB
RTX 3090 24 GB
RTX 4090 24 GB

Точные цифры, конечно, зависят от длины последовательности, batch size и параметров обучения.


Шаг 1. Устанавливаем зависимости


python3 -m venv .venv
source .venv/bin/activate

pip install \
    torch \
    transformers \
    datasets \
    accelerate \
    peft \
    trl \
    bitsandbytes \
    beautifulsoup4 \
    requests \
    sentence-transformers \
    faiss-cpu \
    qdrant-client \
    langsmith

В упрощённом виде:


torch + transformers
→ Qwen

peft + trl
→ LoRA

sentence-transformers
→ embeddings

faiss-cpu
→ семантический поиск

langsmith
→ наблюдаемость

Структура проекта:


song-ai/
├── data/
│   ├── songs.jsonl
│   ├── songs.faiss
│   └── search_documents.json
│
├── models/
│   └── song-lora/
│
└── src/
    ├── collect.py
    ├── prepare.py
    ├── split.py
    ├── train.py
    ├── search.py
    ├── generate.py
    ├── quality.py
    ├── suno.py
    └── pipeline.py

Шаг 2. Собираем корпус


Если тексты уже находятся в TXT, CSV или JSON, этап можно пропустить.


Если они извлекаются из HTML, нужно учитывать условия использования источника и права на произведения.


Старый вариант вроде:


article = soup.body.findAll("article")
text = str(article[0]).split("\n")[8]

слишком хрупкий.


Небольшое изменение HTML — и всё ломается.


Лучше работать непосредственно с элементами:


from dataclasses import dataclass

import requests
from bs4 import BeautifulSoup


HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 "
        "(compatible; SongDatasetResearch/2.0)"
    )
}


@dataclass
class Song:
    title: str
    text: str


def parse_song(url: str) -> Song:
    response = requests.get(
        url,
        headers=HEADERS,
        timeout=20,
    )

    response.raise_for_status()

    soup = BeautifulSoup(
        response.text,
        "html.parser",
    )

    title_node = soup.select_one("h1")
    text_node = soup.select_one("article")

    if title_node is None:
        raise ValueError(
            f"Заголовок не найден: {url}"
        )

    if text_node is None:
        raise ValueError(
            f"Текст не найден: {url}"
        )

    return Song(
        title=title_node.get_text(
            " ",
            strip=True,
        ),
        text=text_node.get_text(
            "\n",
            strip=True,
        ),
    )

Конкретные CSS-селекторы зависят от источника.


Шаг 3. Чистим текст, но не ломаем песню


Здесь легко перестараться.


Нам нужно убрать технический мусор, но сохранить строфы.


import re


def clean_text(text: str) -> str:
    text = text.replace(
        "\r\n",
        "\n",
    ).replace(
        "\r",
        "\n",
    )

    lines = []

    for line in text.splitlines():
        line = re.sub(
            r"[ \t]+",
            " ",
            line,
        ).strip()

        lines.append(line)

    text = "\n".join(lines)

    return re.sub(
        r"\n{3,}",
        "\n\n",
        text,
    ).strip()

Результат должен сохранить примерно такую форму:


строка
строка
строка

следующая строфа
следующая строфа

Шаг 4. Сохраняем всё в JSONL


Почему не обычный TXT?


Потому что нам скоро понадобится не только сам текст.


Минимально:


{"title":"Песня 1","text":"..."}
{"title":"Песня 2","text":"..."}

Позже можно добавить:


год
настроение
темп
темы
жанр

Код:


import json
from pathlib import Path


def save_dataset(
    songs: list[Song],
    filename: str,
) -> None:

    path = Path(filename)

    path.parent.mkdir(
        parents=True,
        exist_ok=True,
    )

    with path.open(
        "w",
        encoding="utf-8",
    ) as file:

        for song in songs:
            text = clean_text(song.text)

            if len(text) < 100:
                continue

            file.write(
                json.dumps(
                    {
                        "title": song.title,
                        "text": text,
                    },
                    ensure_ascii=False,
                )
                + "\n"
            )

Шаг 5. Не режем песни по 1000 символов


Для семантического поиска не всегда удобно индексировать целую песню.


В одной композиции могут быть разные темы:


куплет
→ воспоминания

припев
→ расставание

второй куплет
→ ночной город

Примитивный подход:


text[:1000]

может оборвать строку или строфу посередине.


Поэтому сначала пытаемся делить по естественным границам:


строфы
 ↓
строки
 ↓
предложения
 ↓
части предложений
 ↓
слова

И считаем размер не в символах, а в токенах.


from transformers import AutoTokenizer


MODEL_NAME = "Qwen/Qwen3-1.7B"

tokenizer = AutoTokenizer.from_pretrained(
    MODEL_NAME
)


SEPARATORS = [
    "\n\n",
    "\n",
    ". ",
    "! ",
    "? ",
    "; ",
    ", ",
    " ",
]


def token_count(text: str) -> int:
    return len(
        tokenizer.encode(
            text,
            add_special_tokens=False,
        )
    )

Само рекурсивное разбиение:


def recursive_split(
    text: str,
    max_tokens: int = 350,
    separators: list[str] | None = None,
) -> list[str]:

    text = text.strip()

    if not text:
        return []

    if token_count(text) <= max_tokens:
        return [text]

    if separators is None:
        separators = SEPARATORS

    if not separators:
        token_ids = tokenizer.encode(
            text,
            add_special_tokens=False,
        )

        return [
            tokenizer.decode(
                token_ids[i:i + max_tokens]
            ).strip()
            for i in range(
                0,
                len(token_ids),
                max_tokens,
            )
        ]

    separator = separators[0]
    parts = text.split(separator)

    if len(parts) == 1:
        return recursive_split(
            text,
            max_tokens=max_tokens,
            separators=separators[1:],
        )

    chunks = []
    current = []

    for part in parts:
        candidate = separator.join(
            current + [part]
        ).strip()

        if (
            current
            and token_count(candidate) > max_tokens
        ):
            chunks.extend(
                recursive_split(
                    separator.join(current),
                    max_tokens=max_tokens,
                    separators=separators[1:],
                )
            )

            current = [part]
        else:
            current.append(part)

    if current:
        chunks.extend(
            recursive_split(
                separator.join(current),
                max_tokens=max_tokens,
                separators=separators[1:],
            )
        )

    return [
        chunk.strip()
        for chunk in chunks
        if chunk.strip()
    ]

Для небольшого песенного корпуса этого уже достаточно.


Шаг 6. Обучаем LoRA


Теперь впервые используем корпус для изменения самой модели.


from datasets import load_dataset


dataset = load_dataset(
    "json",
    data_files="data/songs.jsonl",
    split="train",
)


def format_song(example):
    return {
        "text": (
            f"Название: {example['title']}\n\n"
            f"{example['text']}"
        )
    }


dataset = dataset.map(
    format_song
)

dataset = dataset.train_test_split(
    test_size=0.1,
    seed=42,
)

Конфигурация:


from peft import LoraConfig


peft_config = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
    target_modules="all-linear",
)

Обучение:


import torch

from trl import (
    SFTConfig,
    SFTTrainer,
)


training_args = SFTConfig(
    output_dir="models/song-lora",

    num_train_epochs=3,

    per_device_train_batch_size=1,
    per_device_eval_batch_size=1,

    gradient_accumulation_steps=8,

    learning_rate=1e-4,

    eval_strategy="epoch",
    save_strategy="epoch",

    max_length=1024,
    packing=True,

    gradient_checkpointing=True,

    bf16=(
        torch.cuda.is_available()
        and torch.cuda.is_bf16_supported()
    ),

    fp16=(
        torch.cuda.is_available()
        and not torch.cuda.is_bf16_supported()
    ),

    report_to="none",
)


trainer = SFTTrainer(
    model=MODEL_NAME,
    args=training_args,
    train_dataset=dataset["train"],
    eval_dataset=dataset["test"],
    peft_config=peft_config,
)


trainer.train()

trainer.save_model(
    "models/song-lora"
)

Jeżeli VRAM jest problemem, można przejść na QLoRA i trzymać bazową model w 4 bitach.


from transformers import (
    AutoModelForCausalLM,
    BitsAndBytesConfig,
)


quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)


model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    quantization_config=quantization_config,
    device_map="auto",
)

На этом этапе у нас появилась адаптированная модель.


Но мы пока вообще не использовали семантический поиск.


Шаг 7. Готовим корпус для поиска


Каждую песню разбиваем на несколько фрагментов:


def build_search_documents(
    songs: list[dict],
) -> list[dict]:

    documents = []

    for song_id, song in enumerate(songs):
        chunks = recursive_split(
            song["text"],
            max_tokens=300,
        )

        for chunk_id, chunk in enumerate(chunks):
            documents.append(
                {
                    "song_id": song_id,
                    "chunk_id": chunk_id,
                    "title": song["title"],
                    "text": chunk,
                }
            )

    return documents

Теперь поиск работает не только на уровне:


песня целиком

а на уровне:


смысловой фрагмент песни

Это обычно полезнее.


Шаг 8. Превращаем фрагменты в embeddings


from sentence_transformers import (
    SentenceTransformer,
)


embedding_model = SentenceTransformer(
    "intfloat/multilingual-e5-base"
)


def embed_documents(
    texts: list[str],
):
    return embedding_model.encode_document(
        texts,
        normalize_embeddings=True,
        show_progress_bar=True,
    )


def embed_query(
    text: str,
):
    return embedding_model.encode_query(
        [text],
        normalize_embeddings=True,
    )[0]

Теперь каждый фрагмент имеет:


текст
+
вектор смысла

Шаг 9. Строим FAISS-индекс


import faiss
import numpy as np


documents = build_search_documents(
    songs
)

vectors = embed_documents(
    [
        item["text"]
        for item in documents
    ]
)


index = faiss.IndexFlatIP(
    vectors.shape[1]
)

index.add(
    np.asarray(
        vectors,
        dtype="float32",
    )
)


faiss.write_index(
    index,
    "data/songs.faiss"
)

Индекс строится один раз.


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


Шаг 10. Ищем примеры под конкретную тему


def semantic_search(
    query: str,
    top_k: int = 15,
) -> list[dict]:

    vector = embed_query(
        query
    )

    scores, indices = index.search(
        np.asarray(
            [vector],
            dtype="float32",
        ),
        top_k,
    )

    results = []

    for score, idx in zip(
        scores[0],
        indices[0],
    ):
        if idx < 0:
            continue

        results.append(
            {
                **documents[int(idx)],
                "score": float(score),
            }
        )

    return results

Для темы:


случайная встреча бывших
ночью в Москве
спустя десять лет

мы можем получить условно:


Песня A — 0.84
Песня C — 0.81
Песня F — 0.77
Песня A — 0.75

Здесь появляется ещё одна мелочь.


Не хочется отдавать модели три фрагмента одной песни.


Поэтому оставляем разные произведения:


def select_unique_songs(
    results: list[dict],
    count: int = 4,
) -> list[dict]:

    selected = []
    seen = set()

    for result in results:
        song_id = result["song_id"]

        if song_id in seen:
            continue

        seen.add(song_id)
        selected.append(result)

        if len(selected) >= count:
            break

    return selected

И получаем:


candidates = semantic_search(
    topic,
    top_k=16,
)

examples = select_unique_songs(
    candidates,
    count=4,
)

Шаг 11. Теперь примеры попадают в prompt


И только здесь весь предыдущий поиск начинает влиять на генерацию.


тема
 ↓
embedding
 ↓
FAISS
 ↓
4 подходящих фрагмента
 ↓
prompt
 ↓
Qwen + LoRA

Формируем запрос:


def build_prompt(
    examples: list[dict],
    topic: str,
) -> str:

    blocks = []

    for number, example in enumerate(
        examples,
        start=1,
    ):
        blocks.append(
            f"""
### Пример {number}

Название:
{example["title"]}

Фрагмент:
{example["text"]}
""".strip()
        )

    examples_text = "\n\n".join(
        blocks
    )

    return f"""
Ты создаёшь новый оригинальный
текст русской песни.

Ниже приведены примеры,
подобранные по смысловой близости.

Не копируй их строки.
Не продолжай существующие песни.
Не используй исходные названия.

Ориентируйся на:
- построение строф;
- длину строк;
- устройство припева;
- характер повторов;
- общую композиционную форму.

{examples_text}

### Новая песня

Тема:
{topic}

Структура:

[Verse 1]

[Pre-Chorus]

[Chorus]

[Verse 2]

[Chorus]

[Bridge]

[Final Chorus]

Верни только новый текст песни.
""".strip()

Здесь хорошо видно различие:


LoRA
→ меняет модель глобально

FAISS + примеры
→ меняют контекст конкретного запроса

Это два совершенно разных механизма.


Шаг 12. Генерируем текст


Загружаем базовую модель и наш адаптер:


import torch

from peft import PeftModel

from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
)


tokenizer = AutoTokenizer.from_pretrained(
    MODEL_NAME
)


base_model = (
    AutoModelForCausalLM
    .from_pretrained(
        MODEL_NAME,
        torch_dtype="auto",
        device_map="auto",
    )
)


model = PeftModel.from_pretrained(
    base_model,
    "models/song-lora",
)

model.eval()

Генерация:


def generate_song_text(
    prompt: str,
) -> str:

    inputs = tokenizer(
        prompt,
        return_tensors="pt",
    ).to(model.device)

    with torch.no_grad():

        output = model.generate(
            **inputs,
            max_new_tokens=700,
            do_sample=True,
            temperature=0.8,
            top_p=0.92,
            repetition_penalty=1.12,
        )

    generated = output[0][
        inputs["input_ids"].shape[1]:
    ]

    return tokenizer.decode(
        generated,
        skip_special_tokens=True,
    )

Теперь у нас наконец есть новый текст.


Но перед музыкой я бы добавил ещё один предохранитель.


Шаг 13. Проверяем, не получилось ли слишком похоже


Наш корпус использовался дважды:


LoRA
+
примеры в prompt

Поэтому проверка совпадений здесь не выглядит лишней.


Самый простой вариант — n-граммы.


def ngrams(
    text: str,
    n: int = 5,
) -> set[tuple[str, ...]]:

    words = text.lower().split()

    return {
        tuple(words[i:i+n])
        for i in range(
            len(words) - n + 1
        )
    }


def ngram_similarity(
    first: str,
    second: str,
    n: int = 5,
) -> float:

    a = ngrams(first, n)
    b = ngrams(second, n)

    if not a or not b:
        return 0.0

    return len(a & b) / len(a)

Проверяем весь корпус:


def maximum_similarity(
    generated: str,
    songs: list[dict],
) -> tuple[float, str | None]:

    best_score = 0.0
    best_title = None

    for song in songs:
        score = ngram_similarity(
            generated,
            song["text"],
        )

        if score > best_score:
            best_score = score
            best_title = song["title"]

    return best_score, best_title

Логика очень простая:


новый текст
 ↓
проверка
 ↓
подозрительно похож?
 ├── да → не используем
 └── нет → идём дальше

Это не полноценный детектор плагиата.


Но как дополнительный автоматический контроль для эксперимента — вполне разумно.


Шаг 14. LangSmith: чтобы понимать, почему получилось плохо


Теперь pipeline уже не такой простой:


поиск
 ↓
выбор примеров
 ↓
prompt
 ↓
Qwen + LoRA
 ↓
проверка
 ↓
Suno

Если результат не нравится, полезно понимать почему.


Например:


FAISS нашёл странные примеры?

Qwen плохо сгенерировал припев?

параметры генерации слишком случайные?

проблема появилась уже на музыкальном этапе?

Для этого можно использовать LangSmith.


export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY="..."
export LANGSMITH_PROJECT="song-generator"

Например:


from langsmith import traceable


@traceable(
    name="Семантический поиск песен",
    run_type="retriever",
)
def retrieve_examples(
    topic: str,
) -> list[dict]:

    candidates = semantic_search(
        topic,
        top_k=16,
    )

    return select_unique_songs(
        candidates,
        count=4,
    )

В результате один запуск можно видеть как дерево:


Генерация песни

├── Семантический поиск
│   ├── пример A
│   ├── пример B
│   └── пример C
│
├── Prompt
├── Qwen + LoRA
├── Проверка сходства
└── Suno

Для одного запуска это просто удобно.


Для десятков экспериментов становится действительно полезно.


Шаг 15. Теперь превращаем текст в настоящую песню


До сих пор итогом был текст.


Но цель эксперимента всё-таки другая:


текст
+
мелодия
+
вокал
+
аранжировка
 ↓
готовая композиция

Для музыкальной части используем Suno через CRUN API.


Выбираем режим:


"mode": "custom"

потому что текст у нас уже есть.


Нам не нужно, чтобы Suno самостоятельно придумывал слова.


Он должен получить результат предыдущей части pipeline и превратить его в музыку.


Почему структура текста важна и для Suno


Именно поэтому мы ещё в prompt для Qwen использовали:


[Verse 1]

[Pre-Chorus]

[Chorus]

[Verse 2]

[Chorus]

[Bridge]

[Final Chorus]

Это уже не просто форматирование.


Структура проходит через весь pipeline:


prompt
 ↓
структура текста
 ↓
структура музыкальной композиции

Так проще контролировать, где должен быть припев, где куплет, а где переход.


Как описать нужное звучание


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


Например:


Russian nostalgic dance-pop,
late 1990s / early 2000s,
male vocal,
melodic emotional chorus,
simple memorable hook,
bright synthesizers,
electronic drums,
romantic melancholic mood,
medium tempo,
clean modern production

То есть отдельно задаём:


жанр
эпоху
вокал
настроение
аранжировку
характер припева
темп

В результате музыкальной модели даётся значительно более конкретная задача, чем просто:


Russian pop

Шаг 16. Отправляем песню в Suno


Ключ:


export CRUN_API_KEY="..."

Код:


import os
import requests


CRUN_API_KEY = os.environ[
    "CRUN_API_KEY"
]


CREATE_TASK_URL = (
    "https://api.crun.ai/"
    "api/v1/client/job/CreateTask"
)


def create_music(
    title: str,
    lyrics: str,
) -> str:

    payload = {
        "model": "suno/music-generate",

        "callback_url": "",

        "input": {
            "mode": "custom",

            "model": "v5",

            "instrumental": False,

            "title": title,

            "tags": (
                "Russian nostalgic dance-pop, "
                "late 1990s early 2000s, "
                "male vocal, "
                "melodic emotional chorus, "
                "simple memorable hook, "
                "bright synthesizers, "
                "electronic drums, "
                "romantic melancholic mood, "
                "medium tempo, "
                "clean modern production"
            ),

            "lyrics": lyrics,

            "vocal_gender": "m",

            "style_weight": 0.85,

            "weirdness_constraint": 0.2,

            "negative_tags": (
                "metal, aggressive, "
                "extreme distortion"
            ),
        },
    }

    response = requests.post(
        CREATE_TASK_URL,

        headers={
            "x-api-key": CRUN_API_KEY,
            "Content-Type": "application/json",
        },

        json=payload,

        timeout=30,
    )

    response.raise_for_status()

    data = response.json()

    if data.get("code") != 200:
        raise RuntimeError(
            f"Ошибка CRUN: {data}"
        )

    return data["data"]["task_id"]

API возвращает идентификатор задачи:


{
    "code": 200,
    "message": "success",
    "data": {
        "task_id": "task_12345678"
    }
}

По нему затем получаем результат.


TASK_INFO_URL = (
    "https://api.crun.ai/"
    "api/v1/client/job/TaskInfo"
)


def get_music_task(
    task_id: str,
) -> dict:

    response = requests.get(
        TASK_INFO_URL,

        headers={
            "x-api-key": CRUN_API_KEY,
        },

        params={
            "task_id": task_id,
        },

        timeout=30,
    )

    response.raise_for_status()

    return response.json()

В итоге музыкальная часть выглядит так:


сгенерированный текст
 ↓
описание звучания
 ↓
Suno v5
 ↓
готовая композиция

И только здесь эксперимент действительно заканчивается песней.


Что ещё интересно в Suno API


Для основного pipeline достаточно Generate Music.


Но API позволяет продолжить эксперимент.


Extend Music — если первая версия понравилась, но хочется продлить композицию или добавить финал.


Cover Music — для создания другой музыкальной интерпретации исходного материала.


Persona Generate — интересный вариант для серии генераций, где хочется получить более последовательную музыкальную подачу.


Для первой версии проекта это уже необязательно.


Но как направление для следующего эксперимента — вполне интересно.


Собираем всё вместе


Теперь полный pipeline выглядит так:


                    КОРПУС ПЕСЕН
                   /             \
                  ▼               ▼
               LoRA          embeddings
                  │               │
                  │             FAISS
                  │               │
                  │       релевантные примеры
                  │               │
                  └───────┬───────┘
                          ▼
                      Qwen + LoRA
                          │
                          ▼
                     новый текст
                          │
                          ▼
                  контроль сходства
                          │
                          ▼
                описание звучания
                          │
                          ▼
                       Suno v5
                          │
                          ▼
                  готовая песня

И финальная функция:


def generate_full_song(
    topic: str,
) -> dict:

    examples = retrieve_examples(
        topic
    )

    prompt = build_prompt(
        examples,
        topic,
    )

    lyrics = generate_song_text(
        prompt
    )

    similarity, source = (
        maximum_similarity(
            lyrics,
            songs,
        )
    )

    if similarity > 0.25:
        raise ValueError(
            "Слишком сильное совпадение "
            f"с песней: {source}"
        )

    task_id = create_music(
        title="Последняя электричка",
        lyrics=lyrics,
    )

    return {
        "lyrics": lyrics,
        "examples": examples,
        "similarity": similarity,
        "task_id": task_id,
    }

Запуск:


result = generate_full_song(
    topic=(
        "случайная встреча бывших "
        "в ночной Москве "
        "спустя десять лет"
    )
)

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


исходные данные
 ↓
адаптация
 ↓
поиск
 ↓
контекст
 ↓
генерация текста
 ↓
проверка
 ↓
генерация музыки
 ↓
песня

Но работает ли LoRA вообще?


И здесь начинается самая интересная часть.


Можно потратить много времени на архитектуру, LoRA, embeddings и FAISS, а затем обнаружить, что обычный Qwen без всего этого пишет не хуже.


Поэтому просто показать один удачный результат недостаточно.


Нужно сравнение.


Я бы проверил четыре варианта:


Вариант LoRA Примеры Выбор примеров
A нет нет
B да нет
C да да случайный
D да да семантический

То есть:


A
обычный Qwen

B
Qwen + LoRA

C
Qwen + LoRA
+ случайные песни

D
Qwen + LoRA
+ примеры из FAISS

Для честного сравнения фиксируем:


одинаковую тему
одинаковое случайное зерно
одинаковую температуру
одинаковый top_p
одинаковую длину

А затем оцениваем:


  • соответствие теме;
  • связность;
  • структуру;
  • качество припева;
  • повторяемость;
  • оригинальность;
  • сходство с корпусом;
  • человеческую оценку.

Вот это уже настоящий эксперимент.


Самый неудобный результат тоже будет интересным


Предположим:


Base Qwen
≈
Qwen + LoRA

Это не провал.


Это результат.


Он может означать, что:


  • данных слишком мало;
  • LoRA настроена неудачно;
  • корпус недостаточно специфичен;
  • базовая модель уже настолько сильна, что дополнительная адаптация почти ничего не даёт.

Другой вариант:


Qwen + LoRA
+ семантические примеры

>

обычный Qwen

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


Именно поэтому мне здесь намного интереснее сравнение архитектур, чем одна эффектная песня.


А может вообще выбросить LoRA?


Вполне возможно.


Это ещё один нормальный итог эксперимента.


Современные модели настолько сильны, что иногда схема:


Qwen
+
4 хорошо подобранных примера

может оказаться практически такой же хорошей, как:


Qwen
+
LoRA
+
4 примера

Тогда самое дорогое звено pipeline можно просто убрать.


И это хороший инженерный результат.


Не каждая технология, которую можно добавить, должна остаться в production.


Почему не отправить весь корпус в prompt


Ещё один очевидный вопрос.


Почему не сделать просто:


100 песен
+
новая тема
 ↓
Qwen

Потому что большая часть корпуса не имеет отношения к конкретному запросу.


Дополнительный контекст:


  • занимает токены;
  • увеличивает время;
  • создаёт информационный шум;
  • повышает вероятность ненужных совпадений.

Гораздо разумнее:


100 песен
 ↓
поиск
 ↓
4 релевантных примера
 ↓
Qwen

Именно здесь FAISS наконец оправдывает своё существование.


Почему не отправить всё сразу в Suno


Можно.


И это самый короткий pipeline:


описание
 ↓
Suno
 ↓
готовая песня

Если цель — просто получить музыкальный файл, возможно, так и стоит сделать.


Но тогда исчезает сам эксперимент.


Мне интереснее контролировать текстовую часть отдельно:


собственный корпус
 ↓
адаптация
 ↓
семантический поиск
 ↓
Qwen
 ↓
проверяемый текст

И лишь затем:


текст
+
музыкальное описание
 ↓
Suno
 ↓
аудио

Это позволяет отдельно исследовать, что произошло с текстом, а что — уже на музыкальном этапе.


2019 против 2026


В 2019 году:


песни
 ↓
транслитерация
 ↓
RNN
 ↓
новый текст

В 2026 году:


                         песни
                       /   |   \
                      /    |    \
                     ▼     ▼     ▼
                   LoRA  векторы контроль
                     │     │
                     │   FAISS
                     │     │
                     │   примеры
                     │     │
                     └──┬──┘
                        ▼
                       Qwen
                        │
                        ▼
                    новый текст
                        │
                        ▼
                      проверка
                        │
                        ▼
                       Suno
                        │
                        ▼
                   готовая песня

Но главное изменение, на мой взгляд, даже не в количестве блоков.


Изменился сам способ думать о задаче.


Раньше:


есть данные
 ↓
обучаем модель
 ↓
смотрим результат

Сегодня:


есть готовая сильная модель

↓

решаем,
что действительно стоит обучать,
что лучше искать,
что передавать в контекст,
что проверять,
а что вообще отдать другой модели

И это, пожалуй, самый интересный вывод всего эксперимента.


Вывод


В 2019 году маленький корпус был практически всем проектом.


Сегодня он становится лишь одним из элементов системы.


Одни и те же данные можно использовать:


  1. для адаптации модели через LoRA;
  2. для семантического поиска;
  3. как источник релевантных примеров;
  4. для проверки полученного результата.

А сам pipeline уже не заканчивается словами на экране.


Его можно довести до полноценной композиции:


корпус
 ↓
Qwen
 ↓
новый текст
 ↓
контроль
 ↓
Suno
 ↓
музыка + вокал

Но для меня самый интересный вопрос даже не:


Получится ли песня?

С современной генеративной моделью почти наверняка получится.


Интереснее другое:


Какие из всех этих компонентов действительно улучшают результат, а какие мы добавили просто потому, что можем?

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


Я бы сравнил обычный Qwen, Qwen с LoRA, случайные примеры и семантический поиск.


Вполне возможно, что победит самая простая архитектура.


А если нет — у нас будет возможность измеримо показать, что маленький корпус песен спустя семь лет всё ещё может быть полезен.


Просто теперь он учит систему совершенно иначе.


Литература


CRUN (2026a) Suno API: Generate Music. CRUN AI Documentation. Дата обращения: 11 августа 2026 г.


CRUN (2026b) Suno Task Info. CRUN AI Documentation. Дата обращения: 11 августа 2026 г.


Hugging Face (2026a) Fine-tuning. Transformers Documentation. Дата обращения: 11 августа 2026 г.


Hugging Face (2026b) LoRA and PEFT Integration. PEFT and TRL Documentation. Дата обращения: 11 августа 2026 г.


Hugging Face (2026c) Quantization and QLoRA. Transformers Documentation. Дата обращения: 11 августа 2026 г.


LangChain (2026) LangSmith Observability and Evaluation. LangSmith Documentation. Дата обращения: 11 августа 2026 г.


Meta AI (2026) FAISS Documentation. Дата обращения: 11 августа 2026 г.


Qdrant (2026) Vector Search, Collections and Filtering. Qdrant Documentation. Дата обращения: 11 августа 2026 г.


Qwen (2025) Qwen3-1.7B Model Card. Qwen Team.


Sentence Transformers (2026) Semantic Search and Query/Document Encoding. Sentence Transformers Documentation. Дата обращения: 11 августа 2026 г.


Wang, L., Yang, N., Huang, X., Yang, L., Majumder, R. and Wei, F. (2024) Multilingual E5 Text Embeddings: A Technical Report.