
Дисклеймер. Материал подготовлен исключительно в образовательных и исследовательских целях. Автор не связан с группой «Руки Вверх» и не планирует получать прибыль от её творчества. Упоминания произведений и отдельные фрагменты используются только для технического анализа в рамках применимых ограничений авторского права, включая право цитирования. Материал не является инструкцией или призывом к нарушению авторских прав.
От 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 году маленький корпус был практически всем проектом.
Сегодня он становится лишь одним из элементов системы.
Одни и те же данные можно использовать:
- для адаптации модели через LoRA;
- для семантического поиска;
- как источник релевантных примеров;
- для проверки полученного результата.
А сам 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.

