【新增】AI:音乐接入 API KEY 管理

This commit is contained in:
YunaiV 2024-06-29 09:29:37 +08:00
parent 2e9915b77b
commit 949d5a1815
13 changed files with 108 additions and 48 deletions

View File

@ -5,9 +5,9 @@ Authorization: {{token}}
{
"platform": "Suno",
"generateMode": 1,
"prompt": "来一首快乐的歌曲",
"modelVersion": "chirp-v3.5",
"generateMode": 2,
"prompt": "周末啦!",
"model": "chirp-v3.5",
"tags": ["Happy"],
"title": "Happy Song"
}
@ -19,8 +19,8 @@ Authorization: {{token}}
{
"platform": "Suno",
"generateMode": 2,
"prompt": "来一首快乐的歌曲",
"makeInstrumental": false,
"title": "Happy Song"
"generateMode": 1,
"model": "chirp-v3.5",
"gptDescriptionPrompt": "今天是星球六,结果是个下雨天,希望心情很美丽",
"makeInstrumental": false
}

View File

@ -35,6 +35,16 @@ public class AiMusicController {
return success(BeanUtils.toBean(pageResult, AiMusicRespVO.class));
}
@PostMapping("/generate")
@Operation(summary = "音乐生成")
public CommonResult<List<Long>> generateMusic(@RequestBody @Valid AiSunoGenerateReqVO reqVO) {
if (true) {
musicService.syncMusic();
return null;
}
return success(musicService.generateMusic(getLoginUserId(), reqVO));
}
@Operation(summary = "删除【我的】音乐记录")
@DeleteMapping("/delete-my")
@Parameter(name = "id", required = true, description = "音乐编号", example = "1024")
@ -54,6 +64,7 @@ public class AiMusicController {
return success(BeanUtils.toBean(music, AiMusicRespVO.class));
}
// TODO @xin这个搞成 updateMy 修改我的音乐方便后续支持其它字段另外需要校验下更新的音乐是不是我的
@PostMapping("/updateTitle-my")
@Operation(summary = "修改【我的】音乐 目前只支持修改标题")
@Parameter(name = "title", required = true, description = "音乐名称", example = "夜空中最亮的星")
@ -62,12 +73,6 @@ public class AiMusicController {
return success(true);
}
@PostMapping("/generate")
@Operation(summary = "音乐生成")
public CommonResult<List<Long>> generateMusic(@RequestBody @Valid AiSunoGenerateReqVO reqVO) {
return success(musicService.generateMusic(getLoginUserId(), reqVO));
}
// ================ 音乐管理 ================
@GetMapping("/page")
@ -87,11 +92,11 @@ public class AiMusicController {
return success(true);
}
@PutMapping("/update-public-status")
@Operation(summary = "更新音乐发布状态")
@PutMapping("/update")
@Operation(summary = "更新音乐")
@PreAuthorize("@ss.hasPermission('ai:music:update')")
public CommonResult<Boolean> updateMusicPublicStatus(@Valid @RequestBody AiMusicUpdatePublicStatusReqVO updateReqVO) {
musicService.updateMusicPublicStatus(updateReqVO);
public CommonResult<Boolean> updateMusic(@Valid @RequestBody AiMusicUpdateReqVO updateReqVO) {
musicService.updateMusic(updateReqVO);
return success(true);
}

View File

@ -52,6 +52,9 @@ public class AiMusicRespVO {
@Schema(description = "音乐风格标签")
private List<String> tags;
@Schema(description = "音乐时长", example = "[\"pop\",\"jazz\",\"punk\"]")
private Double duration;
@Schema(description = "是否发布", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
private Boolean publicStatus;
@ -61,9 +64,6 @@ public class AiMusicRespVO {
@Schema(description = "错误信息")
private String errorMessage;
@Schema(description = "音乐时长")
private Double duration;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;

View File

@ -4,15 +4,15 @@ import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - AI 音乐修改发布状态 Request VO")
@Schema(description = "管理后台 - AI 音乐修改 Request VO")
@Data
public class AiMusicUpdatePublicStatusReqVO {
public class AiMusicUpdateReqVO {
@Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "15583")
@NotNull(message = "编号不能为空")
private Long id;
@Schema(description = "是否发布", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
@NotNull(message = "是否发布不能为空")
@Schema(description = "是否发布", example = "true")
private Boolean publicStatus;
}

View File

@ -20,11 +20,13 @@ public class AiSunoGenerateReqVO {
* 1. 描述模式描述词 + 是否纯音乐 + 模型
* 2. 歌词模式歌词 + 音乐风格 + 标题 + 模型
*/
@Schema(description = "生成模式 1.描述模式 2. 歌词模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
@Schema(description = "生成模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
@NotNull(message = "生成模式不能为空")
private Integer generateMode; // 参见 AiMusicGenerateModeEnum 枚举
@Schema(description = "歌词模式用:用于生成音乐音频的歌词提示", requiredMode = Schema.RequiredMode.NOT_REQUIRED,
// TODO @xin方案一prompt => lyric 歌词gptDescriptionPrompt => description 描述db 那字段也改下避免和 gpt 直接耦合这样搞完后会更统一好理解一点
// TODO @xin方案二还是之前的做法都用 prompt不过最终 gptDescriptionPrompt 还是存储 description 算描述可以微信一起讨论下
@Schema(description = "用于生成音乐音频的歌词提示",
example = """
[Verse]
阳光下奔跑 多么欢快
@ -37,23 +39,23 @@ public class AiSunoGenerateReqVO {
日子太短暂 别再等待
马上放假了 梦想起飞
""")
private String prompt;
private String prompt; // 歌词模式用
@Schema(description = "描述模式用:用于生成音乐音频的描述", requiredMode = Schema.RequiredMode.NOT_REQUIRED,
@Schema(description = "用于生成音乐音频的描述",
example = "创作一首带有轻松吉他旋律的流行歌曲,[verse] 描述夏日海滩的宁静,[chorus] 节奏加快,表达对自由的向往。")
private String gptDescriptionPrompt;
private String gptDescriptionPrompt; // 描述模式用
@Schema(description = "是否纯音乐", requiredMode = Schema.RequiredMode.NOT_REQUIRED, example = "true")
@Schema(description = "是否纯音乐", example = "true")
private Boolean makeInstrumental;
@Schema(description = "模型", requiredMode = Schema.RequiredMode.REQUIRED, example = "chirp-v3.5")
@NotEmpty(message = "模型不能为空")
private String model; // 参见 AiModelEnum 枚举
@Schema(description = "音乐风格", requiredMode = Schema.RequiredMode.NOT_REQUIRED, example = "[\"pop\",\"jazz\",\"punk\"]")
@Schema(description = "音乐风格", example = "[\"pop\",\"jazz\",\"punk\"]")
private List<String> tags;
@Schema(description = "音乐/歌曲名称", requiredMode = Schema.RequiredMode.NOT_REQUIRED, example = "夜空中最亮的星")
@Schema(description = "音乐/歌曲名称", example = "夜空中最亮的星")
private String title;
}

View File

@ -98,6 +98,11 @@ public class AiMusicDO extends BaseDO {
@TableField(typeHandler = JacksonTypeHandler.class)
private List<String> tags;
/**
* 音乐时长
*/
private Double duration;
/**
* 是否公开
*/
@ -113,10 +118,4 @@ public class AiMusicDO extends BaseDO {
*/
private String errorMessage;
/**
* 音乐时长
*/
private Double duration;
}

View File

@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.ai.service.model;
import cn.iocoder.yudao.framework.ai.core.enums.AiPlatformEnum;
import cn.iocoder.yudao.framework.ai.core.model.suno.api.SunoApi;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.ai.controller.admin.model.vo.apikey.AiApiKeyPageReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.model.vo.apikey.AiApiKeySaveReqVO;
@ -91,4 +92,13 @@ public interface AiApiKeyService {
*/
ImageClient getImageClient(AiPlatformEnum platform);
/**
* 获得 SunoApi 对象
*
* TODO 可优化点目前默认获取 Suno 对应的第一个开启的配置用于音乐后续可以支持配置选择
*
* @return SunoApi 对象
*/
SunoApi getSunoApi();
}

View File

@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.ai.service.model;
import cn.iocoder.yudao.framework.ai.core.enums.AiPlatformEnum;
import cn.iocoder.yudao.framework.ai.core.factory.AiClientFactory;
import cn.iocoder.yudao.framework.ai.core.model.suno.api.SunoApi;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
@ -111,4 +112,14 @@ public class AiApiKeyServiceImpl implements AiApiKeyService {
return clientFactory.getOrCreateImageClient(platform, apiKey.getApiKey(), apiKey.getUrl());
}
@Override
public SunoApi getSunoApi() {
AiApiKeyDO apiKey = apiKeyMapper.selectFirstByPlatformAndStatus(
AiPlatformEnum.SUNO.getPlatform(), CommonStatusEnum.ENABLE.getStatus());
if (apiKey == null) {
return null;
}
return clientFactory.getOrCreateSunoApi(apiKey.getApiKey(), apiKey.getUrl());
}
}

View File

@ -35,7 +35,7 @@ public interface AiMusicService {
*
* @param updateReqVO 更新信息
*/
void updateMusicPublicStatus(@Valid AiMusicUpdatePublicStatusReqVO updateReqVO);
void updateMusic(@Valid AiMusicUpdateReqVO updateReqVO);
/**
* 更新音乐名称
@ -83,4 +83,5 @@ public interface AiMusicService {
* @return 音乐分页
*/
PageResult<AiMusicDO> getMusicMyPage(AiMusicPageReqVO pageReqVO, Long userId);
}

View File

@ -12,6 +12,7 @@ import cn.iocoder.yudao.module.ai.dal.dataobject.music.AiMusicDO;
import cn.iocoder.yudao.module.ai.dal.mysql.music.AiMusicMapper;
import cn.iocoder.yudao.module.ai.enums.music.AiMusicGenerateModeEnum;
import cn.iocoder.yudao.module.ai.enums.music.AiMusicStatusEnum;
import cn.iocoder.yudao.module.ai.service.model.AiApiKeyService;
import cn.iocoder.yudao.module.infra.api.file.FileApi;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@ -35,7 +36,7 @@ import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.MUSIC_NOT_EXIS
public class AiMusicServiceImpl implements AiMusicService {
@Resource
private SunoApi sunoApi;
private AiApiKeyService apiKeyService;
@Resource
private AiMusicMapper musicMapper;
@ -46,6 +47,8 @@ public class AiMusicServiceImpl implements AiMusicService {
@Override
public List<Long> generateMusic(Long userId, AiSunoGenerateReqVO reqVO) {
// 1. 调用 Suno 生成音乐
SunoApi sunoApi = apiKeyService.getSunoApi();
// TODO @xin这两个貌似一直没跑成功你那可以么用的请求是 AiMusicController.http
List<SunoApi.MusicData> musicDataList;
if (Objects.equals(AiMusicGenerateModeEnum.LYRIC.getMode(), reqVO.getGenerateMode())) {
// 1.1 歌词模式
@ -80,6 +83,7 @@ public class AiMusicServiceImpl implements AiMusicService {
log.info("[syncMusic][Suno 开始同步, 共 ({}) 个任务]", streamingTask.size());
// GET 请求为避免参数过长分批次处理
SunoApi sunoApi = apiKeyService.getSunoApi();
CollUtil.split(streamingTask, 36).forEach(chunkList -> {
Map<String, Long> taskIdMap = convertMap(chunkList, AiMusicDO::getTaskId, AiMusicDO::getId);
List<SunoApi.MusicData> musicTaskList = sunoApi.getMusicList(new ArrayList<>(taskIdMap.keySet()));
@ -96,7 +100,7 @@ public class AiMusicServiceImpl implements AiMusicService {
}
@Override
public void updateMusicPublicStatus(AiMusicUpdatePublicStatusReqVO updateReqVO) {
public void updateMusic(AiMusicUpdateReqVO updateReqVO) {
// 校验存在
validateMusicExists(updateReqVO.getId());
// 更新
@ -152,11 +156,16 @@ public class AiMusicServiceImpl implements AiMusicService {
* @return AiMusicDO 集合
*/
private List<AiMusicDO> buildMusicDOList(List<SunoApi.MusicData> musicList) {
// TODO @xin它有 status = error 状态表示失败噢
return convertList(musicList, musicData -> new AiMusicDO()
.setTaskId(musicData.id()).setModel(musicData.modelName())
.setPrompt(musicData.prompt()).setGptDescriptionPrompt(musicData.gptDescriptionPrompt())
.setAudioUrl(createFile(musicData.audioUrl())).setVideoUrl(createFile(musicData.videoUrl())).setImageUrl(createFile(musicData.imageUrl())).setDuration(musicData.duration())
.setTitle(musicData.title()).setLyric(musicData.lyric()).setTags(StrUtil.split(musicData.tags(), StrPool.COMMA))
// TODO @xin只有在完成的状态在下载文件
.setAudioUrl(downloadFile(musicData.audioUrl()))
.setVideoUrl(downloadFile(musicData.videoUrl()))
.setImageUrl(downloadFile(musicData.imageUrl()))
.setTitle(musicData.title()).setDuration(musicData.duration())
.setLyric(musicData.lyric()).setTags(StrUtil.split(musicData.tags(), StrPool.COMMA))
.setStatus(Objects.equals("complete", musicData.status()) ?
AiMusicStatusEnum.SUCCESS.getStatus() : AiMusicStatusEnum.IN_PROGRESS.getStatus()));
}
@ -167,12 +176,17 @@ public class AiMusicServiceImpl implements AiMusicService {
* @param url 音频文件地址
* @return 内部文件地址
*/
private String createFile(String url) {
private String downloadFile(String url) {
if (StrUtil.isBlank(url)) {
return null;
}
try {
byte[] bytes = HttpUtil.downloadBytes(url);
return fileApi.createFile(bytes);
} catch (Exception e) {
log.error("[downloadFile][url({}) 下载失败]", url, e);
return url;
}
}
/**

View File

@ -1,6 +1,7 @@
package cn.iocoder.yudao.framework.ai.core.factory;
import cn.iocoder.yudao.framework.ai.core.enums.AiPlatformEnum;
import cn.iocoder.yudao.framework.ai.core.model.suno.api.SunoApi;
import org.springframework.ai.chat.StreamingChatClient;
import org.springframework.ai.image.ImageClient;
@ -55,4 +56,15 @@ public interface AiClientFactory {
*/
ImageClient getOrCreateImageClient(AiPlatformEnum platform, String apiKey, String url);
/**
* 基于指定配置获得 SunoApi 对象
*
* 如果不存在则进行创建
*
* @param apiKey API KEY
* @param url API URL
* @return SunoApi 对象
*/
SunoApi getOrCreateSunoApi(String apiKey, String url);
}

View File

@ -9,6 +9,7 @@ import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.ai.config.YudaoAiAutoConfiguration;
import cn.iocoder.yudao.framework.ai.config.YudaoAiProperties;
import cn.iocoder.yudao.framework.ai.core.enums.AiPlatformEnum;
import cn.iocoder.yudao.framework.ai.core.model.suno.api.SunoApi;
import cn.iocoder.yudao.framework.ai.core.model.tongyi.QianWenChatClient;
import cn.iocoder.yudao.framework.ai.core.model.tongyi.QianWenChatModal;
import cn.iocoder.yudao.framework.ai.core.model.tongyi.api.QianWenApi;
@ -109,6 +110,11 @@ public class AiClientFactoryImpl implements AiClientFactory {
}
}
@Override
public SunoApi getOrCreateSunoApi(String apiKey, String url) {
return new SunoApi(url);
}
private static String buildClientCacheKey(Class<?> clazz, Object... params) {
if (ArrayUtil.isEmpty(params)) {
return clazz.getName();

View File

@ -201,8 +201,8 @@ yudao.ai:
notify-url: http://java.nat300.top/admin-api/ai/image/midjourney/notify
suno:
enable: true
base-url: https://suno-55ishh05u-status2xxs-projects.vercel.app
# base-url: http://127.0.0.1:3001
# base-url: https://suno-55ishh05u-status2xxs-projects.vercel.app
base-url: http://127.0.0.1:3001
--- #################### 芋道相关配置 ####################