346
qwen3-tts-frontend/src/lib/api.ts
Normal file
346
qwen3-tts-frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import axios from 'axios'
|
||||
import type { LoginRequest, LoginResponse, User } from '@/types/auth'
|
||||
import type { Job, JobCreateResponse, JobListResponse, JobType } from '@/types/job'
|
||||
import type { Language, Speaker, CustomVoiceForm, VoiceDesignForm, VoiceCloneForm } from '@/types/tts'
|
||||
import type { UserCreateRequest, UserUpdateRequest, UserListResponse } from '@/types/user'
|
||||
import { API_ENDPOINTS, LANGUAGE_NAMES, SPEAKER_DESCRIPTIONS_ZH } from '@/lib/constants'
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
interface ValidationError {
|
||||
type: string
|
||||
loc: string[]
|
||||
msg: string
|
||||
input?: any
|
||||
ctx?: any
|
||||
}
|
||||
|
||||
const FIELD_NAMES: Record<string, string> = {
|
||||
username: '用户名',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
is_active: '激活状态',
|
||||
is_superuser: '超级管理员',
|
||||
}
|
||||
|
||||
const formatValidationErrors = (errors: ValidationError[]): string => {
|
||||
return errors.map((error) => {
|
||||
const fieldPath = error.loc.slice(1)
|
||||
const fieldName = fieldPath[fieldPath.length - 1]
|
||||
const translatedField = FIELD_NAMES[fieldName] || fieldName
|
||||
|
||||
switch (error.type) {
|
||||
case 'string_pattern_mismatch':
|
||||
if (fieldName === 'username') {
|
||||
return `${translatedField}只能包含字母、数字、下划线和连字符`
|
||||
}
|
||||
return `${translatedField}格式不正确`
|
||||
|
||||
case 'string_too_short':
|
||||
return `${translatedField}长度不能少于${error.ctx?.min_length || '指定'}个字符`
|
||||
|
||||
case 'string_too_long':
|
||||
return `${translatedField}长度不能超过${error.ctx?.max_length || '指定'}个字符`
|
||||
|
||||
case 'value_error':
|
||||
if (error.msg.includes('uppercase')) {
|
||||
return `${translatedField}必须包含至少一个大写字母`
|
||||
}
|
||||
if (error.msg.includes('lowercase')) {
|
||||
return `${translatedField}必须包含至少一个小写字母`
|
||||
}
|
||||
if (error.msg.includes('digit')) {
|
||||
return `${translatedField}必须包含至少一个数字`
|
||||
}
|
||||
if (error.msg.includes('alphanumeric')) {
|
||||
return `${translatedField}只能包含字母、数字、下划线和连字符`
|
||||
}
|
||||
return `${translatedField}: ${error.msg}`
|
||||
|
||||
case 'missing':
|
||||
return `${translatedField}为必填项`
|
||||
|
||||
case 'value_error.email':
|
||||
return `${translatedField}格式不正确`
|
||||
|
||||
default:
|
||||
return `${translatedField}: ${error.msg}`
|
||||
}
|
||||
}).join('; ')
|
||||
}
|
||||
|
||||
export const formatApiError = (error: any): string => {
|
||||
if (!error.response) {
|
||||
if (error.message === 'Network Error' || !navigator.onLine) {
|
||||
return '网络连接失败,请检查您的网络连接'
|
||||
}
|
||||
return error.message || '请求失败,请稍后重试'
|
||||
}
|
||||
|
||||
const status = error.response.status
|
||||
const data = error.response.data
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
if (data?.detail) {
|
||||
if (typeof data.detail === 'string') {
|
||||
return data.detail
|
||||
}
|
||||
if (Array.isArray(data.detail)) {
|
||||
return data.detail.map((err: any) => err.msg || err.message).join('; ')
|
||||
}
|
||||
}
|
||||
return '请求参数错误,请检查输入'
|
||||
|
||||
case 422:
|
||||
if (data?.detail && Array.isArray(data.detail)) {
|
||||
return formatValidationErrors(data.detail)
|
||||
}
|
||||
return '输入验证失败,请检查表单'
|
||||
|
||||
case 401:
|
||||
return '认证失败,请重新登录'
|
||||
|
||||
case 403:
|
||||
return '没有权限执行此操作'
|
||||
|
||||
case 404:
|
||||
return '请求的资源不存在'
|
||||
|
||||
case 413:
|
||||
return '上传文件过大,请选择较小的文件'
|
||||
|
||||
case 429:
|
||||
return '请求过于频繁,请稍后再试'
|
||||
|
||||
case 500:
|
||||
return '服务器错误,请稍后重试'
|
||||
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return '服务暂时不可用,请稍后重试'
|
||||
|
||||
default:
|
||||
return data?.detail || data?.message || `请求失败 (${status})`
|
||||
}
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && window.location.pathname !== '/login') {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
error.message = formatApiError(error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
|
||||
const params = new URLSearchParams()
|
||||
params.append('username', credentials.username)
|
||||
params.append('password', credentials.password)
|
||||
|
||||
const response = await apiClient.post<LoginResponse>(
|
||||
API_ENDPOINTS.AUTH.LOGIN,
|
||||
params,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getCurrentUser: async (): Promise<User> => {
|
||||
const response = await apiClient.get<User>(API_ENDPOINTS.AUTH.ME)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export const ttsApi = {
|
||||
getLanguages: async (): Promise<Language[]> => {
|
||||
const response = await apiClient.get<string[]>(API_ENDPOINTS.TTS.LANGUAGES)
|
||||
return response.data.map((lang) => ({
|
||||
code: lang,
|
||||
name: LANGUAGE_NAMES[lang] || lang,
|
||||
}))
|
||||
},
|
||||
|
||||
getSpeakers: async (): Promise<Speaker[]> => {
|
||||
const response = await apiClient.get<Speaker[]>(API_ENDPOINTS.TTS.SPEAKERS)
|
||||
return response.data.map((speaker) => ({
|
||||
name: speaker.name,
|
||||
description: SPEAKER_DESCRIPTIONS_ZH[speaker.name] || speaker.description,
|
||||
}))
|
||||
},
|
||||
|
||||
createCustomVoiceJob: async (data: CustomVoiceForm): Promise<JobCreateResponse> => {
|
||||
const response = await apiClient.post<JobCreateResponse>(API_ENDPOINTS.TTS.CUSTOM_VOICE, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createVoiceDesignJob: async (data: VoiceDesignForm): Promise<JobCreateResponse> => {
|
||||
const response = await apiClient.post<JobCreateResponse>(API_ENDPOINTS.TTS.VOICE_DESIGN, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createVoiceCloneJob: async (data: VoiceCloneForm): Promise<JobCreateResponse> => {
|
||||
const formData = new FormData()
|
||||
formData.append('text', data.text)
|
||||
if (data.ref_audio) {
|
||||
formData.append('ref_audio', data.ref_audio)
|
||||
}
|
||||
if (data.language) {
|
||||
formData.append('language', data.language)
|
||||
}
|
||||
if (data.ref_text) {
|
||||
formData.append('ref_text', data.ref_text)
|
||||
}
|
||||
if (data.use_cache !== undefined) {
|
||||
formData.append('use_cache', String(data.use_cache))
|
||||
}
|
||||
if (data.x_vector_only_mode !== undefined) {
|
||||
formData.append('x_vector_only_mode', String(data.x_vector_only_mode))
|
||||
}
|
||||
if (data.max_new_tokens !== undefined) {
|
||||
formData.append('max_new_tokens', String(data.max_new_tokens))
|
||||
}
|
||||
if (data.temperature !== undefined) {
|
||||
formData.append('temperature', String(data.temperature))
|
||||
}
|
||||
if (data.top_k !== undefined) {
|
||||
formData.append('top_k', String(data.top_k))
|
||||
}
|
||||
if (data.top_p !== undefined) {
|
||||
formData.append('top_p', String(data.top_p))
|
||||
}
|
||||
if (data.repetition_penalty !== undefined) {
|
||||
formData.append('repetition_penalty', String(data.repetition_penalty))
|
||||
}
|
||||
|
||||
const response = await apiClient.post<JobCreateResponse>(
|
||||
API_ENDPOINTS.TTS.VOICE_CLONE,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
const normalizeJobType = (jobType: string): JobType => {
|
||||
const typeMap: Record<string, JobType> = {
|
||||
'custom-voice': 'custom_voice',
|
||||
'voice-design': 'voice_design',
|
||||
'voice-clone': 'voice_clone',
|
||||
}
|
||||
return typeMap[jobType] || jobType as JobType
|
||||
}
|
||||
|
||||
const normalizeJob = (job: any): Job => {
|
||||
let parameters = job.input_params || job.parameters || {}
|
||||
|
||||
if (typeof parameters === 'string') {
|
||||
try {
|
||||
parameters = JSON.parse(parameters)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse job parameters:', e)
|
||||
parameters = {}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...job,
|
||||
type: normalizeJobType(job.job_type || job.type),
|
||||
parameters,
|
||||
audio_url: job.download_url || job.audio_url,
|
||||
}
|
||||
}
|
||||
|
||||
export const jobApi = {
|
||||
getJob: async (id: number): Promise<Job> => {
|
||||
const response = await apiClient.get<any>(API_ENDPOINTS.JOBS.GET(id))
|
||||
return normalizeJob(response.data)
|
||||
},
|
||||
|
||||
listJobs: async (skip = 0, limit = 100, status?: string): Promise<JobListResponse> => {
|
||||
const params: Record<string, any> = { skip, limit }
|
||||
if (status) params.status = status
|
||||
const response = await apiClient.get<any>(API_ENDPOINTS.JOBS.LIST, { params })
|
||||
return {
|
||||
...response.data,
|
||||
jobs: response.data.jobs.map(normalizeJob),
|
||||
}
|
||||
},
|
||||
|
||||
deleteJob: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(API_ENDPOINTS.JOBS.DELETE(id))
|
||||
},
|
||||
|
||||
getAudioUrl: (id: number, audioPath?: string): string => {
|
||||
if (audioPath) {
|
||||
if (audioPath.startsWith('http')) {
|
||||
return audioPath
|
||||
} else {
|
||||
const baseUrl = import.meta.env.VITE_API_URL
|
||||
const normalizedPath = audioPath.startsWith('/') ? audioPath : `/${audioPath}`
|
||||
return `${baseUrl}${normalizedPath}`
|
||||
}
|
||||
} else {
|
||||
return `${import.meta.env.VITE_API_URL}${API_ENDPOINTS.JOBS.AUDIO(id)}`
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const userApi = {
|
||||
listUsers: async (skip = 0, limit = 100): Promise<UserListResponse> => {
|
||||
const response = await apiClient.get<UserListResponse>(
|
||||
API_ENDPOINTS.USERS.LIST,
|
||||
{ params: { skip, limit } }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getUser: async (id: number): Promise<User> => {
|
||||
const response = await apiClient.get<User>(API_ENDPOINTS.USERS.GET(id))
|
||||
return response.data
|
||||
},
|
||||
|
||||
createUser: async (data: UserCreateRequest): Promise<User> => {
|
||||
const response = await apiClient.post<User>(API_ENDPOINTS.USERS.CREATE, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateUser: async (id: number, data: UserUpdateRequest): Promise<User> => {
|
||||
const response = await apiClient.put<User>(API_ENDPOINTS.USERS.UPDATE(id), data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteUser: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(API_ENDPOINTS.USERS.DELETE(id))
|
||||
},
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
214
qwen3-tts-frontend/src/lib/constants.ts
Normal file
214
qwen3-tts-frontend/src/lib/constants.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
export const API_ENDPOINTS = {
|
||||
AUTH: {
|
||||
LOGIN: '/auth/token',
|
||||
ME: '/auth/me',
|
||||
},
|
||||
TTS: {
|
||||
LANGUAGES: '/tts/languages',
|
||||
SPEAKERS: '/tts/speakers',
|
||||
CUSTOM_VOICE: '/tts/custom-voice',
|
||||
VOICE_DESIGN: '/tts/voice-design',
|
||||
VOICE_CLONE: '/tts/voice-clone',
|
||||
},
|
||||
JOBS: {
|
||||
LIST: '/jobs',
|
||||
GET: (id: number) => `/jobs/${id}`,
|
||||
DELETE: (id: number) => `/jobs/${id}`,
|
||||
AUDIO: (id: number) => `/jobs/${id}/download`,
|
||||
},
|
||||
USERS: {
|
||||
LIST: '/users',
|
||||
CREATE: '/users',
|
||||
GET: (id: number) => `/users/${id}`,
|
||||
UPDATE: (id: number) => `/users/${id}`,
|
||||
DELETE: (id: number) => `/users/${id}`,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const LANGUAGE_NAMES: Record<string, string> = {
|
||||
'Auto': '自动检测',
|
||||
'Chinese': '中文',
|
||||
'English': '英语',
|
||||
'Japanese': '日语',
|
||||
'Korean': '韩语',
|
||||
'German': '德语',
|
||||
'French': '法语',
|
||||
'Russian': '俄语',
|
||||
'Portuguese': '葡萄牙语',
|
||||
'Spanish': '西班牙语',
|
||||
'Italian': '意大利语',
|
||||
'Cantonese': '粤语',
|
||||
}
|
||||
|
||||
export const SPEAKER_DESCRIPTIONS_ZH: Record<string, string> = {
|
||||
'Vivian': '女性,专业清晰',
|
||||
'Serena': '女性,温柔温暖',
|
||||
'Uncle_Fu': '男性,成熟权威',
|
||||
'Dylan': '男性,年轻活力',
|
||||
'Eric': '男性,沉稳稳重',
|
||||
'Ryan': '男性,友好随和',
|
||||
'Aiden': '男性,低沉浑厚',
|
||||
'Ono_Anna': '女性,可爱活泼',
|
||||
'Sohee': '女性,柔和悦耳',
|
||||
}
|
||||
|
||||
export const DEFAULT_FORM_VALUES = {
|
||||
CUSTOM_VOICE: {
|
||||
text: '',
|
||||
language: 'Auto',
|
||||
speaker: '',
|
||||
instruct: '',
|
||||
},
|
||||
VOICE_DESIGN: {
|
||||
text: '',
|
||||
language: 'Auto',
|
||||
instruct: '',
|
||||
},
|
||||
VOICE_CLONE: {
|
||||
text: '',
|
||||
ref_audio: null,
|
||||
ref_text: '',
|
||||
},
|
||||
} as const
|
||||
|
||||
export const PRESET_INSTRUCTS = [
|
||||
{
|
||||
label: '开心',
|
||||
instruct: '非常开心',
|
||||
text: '今天天气真好,我们一起去公园玩吧!',
|
||||
},
|
||||
{
|
||||
label: '悲伤',
|
||||
instruct: '很悲伤,带着哭腔',
|
||||
text: '对不起,我真的尽力了,但还是让你失望了。',
|
||||
},
|
||||
{
|
||||
label: '愤怒',
|
||||
instruct: '非常愤怒,语气激烈',
|
||||
text: '你怎么能这样做!这简直太过分了!',
|
||||
},
|
||||
{
|
||||
label: '温柔关怀',
|
||||
instruct: '温柔体贴的女性声音,语速平缓,音调柔和,充满关怀和安慰',
|
||||
text: '别担心,一切都会好起来的。我会一直陪在你身边。',
|
||||
},
|
||||
{
|
||||
label: '兴奋激动',
|
||||
instruct: '非常兴奋激动,语速加快,音调上扬,充满活力和热情',
|
||||
text: '太棒了!我们终于成功了!这真是太令人激动了!',
|
||||
},
|
||||
{
|
||||
label: '焦虑紧张',
|
||||
instruct: '焦虑不安的语气,语速略快,音调不稳定,带有紧张和担忧',
|
||||
text: '怎么办?时间不够了,我们来不及了,这可怎么办才好?',
|
||||
},
|
||||
{
|
||||
label: '专业播音员',
|
||||
instruct: '专业新闻播音员。性别:女性。音高:中等偏高,音域稳定。语速:标准播音语速,吐字清晰。音量:适中,音色饱满。情绪:沉稳专业,不带个人感情色彩。语调:平直中略有起伏,重点词汇加重。性格特征:严谨、客观、权威。',
|
||||
text: '据新华社报道,我国航天事业取得重大突破,神舟系列飞船成功完成载人飞行任务。',
|
||||
},
|
||||
{
|
||||
label: '温暖导师',
|
||||
instruct: '温暖的中年女性导师。音色:温和醇厚,带有亲和力。语速:不急不缓,娓娓道来。音调:平稳中带有鼓励性上扬。情绪:关怀、耐心、鼓励。性格:善解人意,循循善诱,充满正能量。适合场景:心理咨询、教育引导。',
|
||||
text: '每个人都有自己的节奏,不要着急。慢慢来,你一定能找到属于自己的那条路。',
|
||||
},
|
||||
{
|
||||
label: '活力少年',
|
||||
instruct: '充满活力的青少年男性。音高:略高,富有朝气。语速:偏快,吐字利落。音量:响亮明快。情绪:开朗乐观,精力充沛。语调:跳跃感强,抑扬顿挫。性格:外向、自信、热情,充满青春气息。',
|
||||
text: '哇,这个游戏太酷了!咱们组队一起玩吧,我保证带你们飞!',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const PRESET_VOICE_DESIGNS = [
|
||||
{
|
||||
label: '甜美少女',
|
||||
instruct: '年轻女性,音色清甜明亮,略带少女的娇俏感。音高偏高,语调活泼富于变化。语速适中,吐字清晰。情绪愉悦轻松,充满青春活力。适合场景:客服语音、语音助手、娱乐内容。',
|
||||
text: '您好,很高兴为您服务!请问有什么可以帮助您的吗?',
|
||||
},
|
||||
{
|
||||
label: '成熟女性',
|
||||
instruct: '成熟知性的女性声音,音色温润饱满,带有职业女性的干练气质。音高中等,音域稳定。语速适中偏快,条理清晰。情绪从容自信,传递专业可靠的感觉。',
|
||||
text: '根据最新的市场分析报告,本季度业绩呈现稳步增长态势,各项指标均达到预期目标。',
|
||||
},
|
||||
{
|
||||
label: '磁性男声',
|
||||
instruct: '中低音男性声音,音色深沉磁性,富有感染力。语速偏慢,节奏沉稳。音量适中,声音浑厚有力。适合情感类、故事讲述、品牌宣传等场景。',
|
||||
text: '夜深了,城市的灯火依然璀璨。每一盏灯下,都有一个关于梦想的故事。',
|
||||
},
|
||||
{
|
||||
label: '活力青年',
|
||||
instruct: '充满活力的年轻男性,音色明亮清晰,带有青春朝气。语速较快,节奏感强。情绪热情积极,富有感染力。适合运动、游戏、娱乐等场景。',
|
||||
text: '兄弟们,准备好了吗?今天我们要挑战全新的副本,冲冲冲!',
|
||||
},
|
||||
{
|
||||
label: '权威专家',
|
||||
instruct: '中年男性专家形象,音色沉稳权威,声音浑厚有力。语速适中,吐字清晰标准。情绪严肃专业,传递信任感和专业度。适合学术讲座、知识科普、正式场合。',
|
||||
text: '从历史发展的角度来看,科技创新始终是推动社会进步的核心动力。',
|
||||
},
|
||||
{
|
||||
label: '温柔妈妈',
|
||||
instruct: '温柔慈爱的中年女性,音色柔和温暖,充满母性关怀。语速舒缓,音调平和安抚。情绪温暖体贴,给人安全感。适合儿童内容、情感陪伴、睡前故事。',
|
||||
text: '宝贝,该睡觉了。妈妈给你讲个故事,从前有一只小兔子,它住在森林里...',
|
||||
},
|
||||
{
|
||||
label: '播音主持',
|
||||
instruct: '专业播音主持人声音,音色饱满圆润,标准普通话发音。音高适中,音域宽广。语速标准,节奏把控精准。情绪专业沉稳,字正腔圆。适合新闻播报、节目主持、正式朗读。',
|
||||
text: '各位听众朋友大家好,欢迎收听今天的节目。接下来为您带来今日要闻。',
|
||||
},
|
||||
{
|
||||
label: '俏皮少女',
|
||||
instruct: '俏皮可爱的少女音色,声音轻快灵动,带有少女特有的活泼感。音调偏高且富于变化,语气中带有撒娇和卖萌的元素。语速时快时慢,吐字清晰但带有可爱的语气词。',
|
||||
text: '哎呀,人家不是故意的嘛~你就原谅我一次好不好?拜托拜托啦~',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const PRESET_REF_TEXTS = [
|
||||
{
|
||||
label: '自然生活',
|
||||
text: '在这个快节奏的世界里,我们总是在赶路,却忘了停下来听听内心的声音。其实,生活不仅仅是眼前的忙碌,还有远方的诗意和偶然发现的小确幸。希望这段录音,能像午后的微风一样,带给你一点点温柔和力量。无论未来如何变化,请记得保持对生活的热爱,去拥抱每一个灿烂的明天。',
|
||||
},
|
||||
{
|
||||
label: '专业正式',
|
||||
text: '科技的进步让我们能够跨越时空的界限,用数字化的方式延续情感与记忆。语音克隆不仅是精密的代码逻辑,更是连接人类与未来智能的纽带。通过深度学习与神经网络的不断演进,每一个细微的语调起伏,都能被精准地捕捉。让我们共同见证,技术如何赋予声音更具生命力的表达。',
|
||||
},
|
||||
{
|
||||
label: '文学叙事',
|
||||
text: '春天的风拂过柳梢,带着泥土的芬芳和花开的消息。你是否也曾期待过,在某个街角的转弯处,遇见那个久违的自己?无论是高亢的欢笑,还是低沉的呢喃,每一种声音都是独一无二的生命印记。让我们在此刻记录当下,让回忆在流淌的声音里,化作永恒的旋律。',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const POLL_INTERVAL = 2000
|
||||
|
||||
export const TIMEOUT_WARNING = 30000
|
||||
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
export const MIN_AUDIO_DURATION = 3
|
||||
|
||||
export const ADVANCED_PARAMS_INFO = {
|
||||
max_new_tokens: {
|
||||
label: '最大生成长度',
|
||||
description: '控制生成音频的最大长度。值越大,可生成的音频越长,但处理时间也会增加',
|
||||
tooltip: '建议值: 2048-4096。超过 8000 可能导致生成时间过长',
|
||||
},
|
||||
temperature: {
|
||||
label: '温度',
|
||||
description: '控制生成的随机性。值越高生成越随机多样,值越低越稳定一致',
|
||||
tooltip: '推荐范围: 0.1-0.5 (稳定) | 0.6-1.0 (多样) | >1.0 (创意)',
|
||||
},
|
||||
top_k: {
|
||||
label: 'Top K',
|
||||
description: '采样时只考虑概率最高的 K 个候选。值越小生成越确定,越大越多样',
|
||||
tooltip: '常用值: 20-50。过小可能导致重复,过大可能不连贯',
|
||||
},
|
||||
top_p: {
|
||||
label: 'Top P (核采样)',
|
||||
description: '累积概率阈值,只从累积概率达到 P 的候选中采样。控制输出多样性',
|
||||
tooltip: '推荐值: 0.7-0.9。0.9 更自然多变,0.7 更稳定可控',
|
||||
},
|
||||
repetition_penalty: {
|
||||
label: '重复惩罚',
|
||||
description: '惩罚重复内容的生成。值越大越避免重复,但过大可能影响自然度',
|
||||
tooltip: '建议范围: 1.0-1.2。1.0 表示无惩罚,1.05 适合大多数场景',
|
||||
},
|
||||
} as const
|
||||
60
qwen3-tts-frontend/src/lib/utils.ts
Normal file
60
qwen3-tts-frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
export function getRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSecs = Math.floor(diffMs / 1000)
|
||||
const diffMins = Math.floor(diffSecs / 60)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffSecs < 60) return '刚刚'
|
||||
if (diffMins < 60) return `${diffMins}分钟前`
|
||||
if (diffHours < 24) return `${diffHours}小时前`
|
||||
if (diffDays < 7) return `${diffDays}天前`
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
export function getAudioDuration(file: File): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio()
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
resolve(Math.floor(audio.duration))
|
||||
})
|
||||
audio.addEventListener('error', () => {
|
||||
reject(new Error('Failed to load audio file'))
|
||||
})
|
||||
audio.src = URL.createObjectURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null
|
||||
return function(...args: Parameters<T>) {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => func(...args), wait)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user