feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,165 @@
export const MAX_STT_AUDIO_BYTES = 25 * 1024 * 1024
export const STT_PROVIDER_TIMEOUT_MS = 60_000
export interface NormalizedSttResult {
transcript: string
confidence: number
language_code: string
duration_seconds: number
provider: string
}
export function normalizeSttResult(value: unknown): NormalizedSttResult {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('invalid_stt_result')
}
const result = value as Record<string, unknown>
if (
typeof result.transcript !== 'string'
|| !result.transcript.trim()
|| result.transcript.length > 1_000_000
|| typeof result.confidence !== 'number'
|| !Number.isFinite(result.confidence)
|| result.confidence < 0
|| result.confidence > 1
|| typeof result.language_code !== 'string'
|| !/^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/i.test(result.language_code)
|| typeof result.duration_seconds !== 'number'
|| !Number.isFinite(result.duration_seconds)
|| result.duration_seconds < 0
|| result.duration_seconds > 24 * 60 * 60
|| typeof result.provider !== 'string'
|| !/^[a-z0-9._-]{1,64}$/.test(result.provider)
) {
throw new Error('invalid_stt_result')
}
return {
transcript: result.transcript.trim(),
confidence: result.confidence,
language_code: result.language_code.toLowerCase(),
duration_seconds: result.duration_seconds,
provider: result.provider,
}
}
export function createInternalSttGatewayUrl(baseUrl: string): string {
const url = new URL('/api/stt/internal/transcribe', baseUrl)
const localHttp = url.protocol === 'http:' && [
'localhost',
'127.0.0.1',
'host.docker.internal',
'd3ro-api-server',
].includes(url.hostname)
if ((url.protocol !== 'https:' && !localHttp) || url.username || url.password) {
throw new Error('invalid_stt_gateway_url')
}
return url.toString()
}
const AUDIO_EXTENSIONS: Readonly<Record<string, string>> = Object.freeze({
'audio/aac': 'aac',
'audio/flac': 'flac',
'audio/mp3': 'mp3',
'audio/mp4': 'm4a',
'audio/mpeg': 'mp3',
'audio/ogg': 'ogg',
'audio/wav': 'wav',
'audio/webm': 'webm',
'audio/x-flac': 'flac',
'audio/x-m4a': 'm4a',
'audio/x-wav': 'wav',
'application/ogg': 'ogg',
'video/mp4': 'mp4',
'video/quicktime': 'mov',
'video/webm': 'webm',
})
export class SttInputError extends Error {
constructor(
readonly code: 'audio_empty' | 'audio_too_large' | 'unsupported_audio_type' | 'invalid_language',
readonly status: 400 | 413 | 415,
) {
super(code)
this.name = 'SttInputError'
}
}
export interface DictionaryHintRow {
word?: unknown
pronunciation?: unknown
}
export interface DictionaryHints {
keyterms: string[]
prompt: string
}
function compactText(value: unknown, maxLength: number): string {
if (typeof value !== 'string') return ''
const withoutControls = Array.from(value, (character) => {
const code = character.charCodeAt(0)
return code < 32 || code === 127 ? ' ' : character
}).join('')
return withoutControls.replace(/\s+/g, ' ').trim().slice(0, maxLength)
}
export function validateSttAudio(audio: Blob, languageCode: string): {
contentType: string
fileName: string
languageCode: string
} {
if (audio.size <= 0) throw new SttInputError('audio_empty', 400)
if (audio.size > MAX_STT_AUDIO_BYTES) throw new SttInputError('audio_too_large', 413)
const contentType = audio.type.toLowerCase().split(';', 1)[0].trim()
const extension = AUDIO_EXTENSIONS[contentType]
if (!extension) throw new SttInputError('unsupported_audio_type', 415)
const language = languageCode.trim()
if (!/^(auto|multi|[a-z]{2,3}(?:-[a-z0-9]{2,8})?)$/i.test(language)) {
throw new SttInputError('invalid_language', 400)
}
return {
contentType,
fileName: `audio.${extension}`,
languageCode: language.toLowerCase(),
}
}
export function buildDictionaryHints(rows: readonly DictionaryHintRow[]): DictionaryHints {
const seen = new Set<string>()
const keyterms: string[] = []
const promptParts: string[] = []
let promptLength = 0
for (const row of rows.slice(0, 100)) {
const word = compactText(row.word, 120)
if (!word) continue
const identity = word.toLocaleLowerCase()
if (seen.has(identity)) continue
const pronunciation = compactText(row.pronunciation, 120)
const promptPart = pronunciation ? `${word} (${pronunciation})` : word
const nextLength = promptLength + promptPart.length + (promptParts.length > 0 ? 2 : 0)
if (nextLength > 900) break
seen.add(identity)
keyterms.push(word)
promptParts.push(promptPart)
promptLength = nextLength
if (keyterms.length >= 50) break
}
return { keyterms, prompt: promptParts.join(', ') }
}
export function createDeepgramSttUrl(languageCode: string, keyterms: readonly string[]): string {
const url = new URL('https://api.deepgram.com/v1/listen')
url.searchParams.set('model', 'nova-3')
url.searchParams.set('language', languageCode === 'auto' ? 'multi' : languageCode)
url.searchParams.set('smart_format', 'true')
url.searchParams.set('punctuate', 'true')
for (const keyterm of keyterms.slice(0, 50)) url.searchParams.append('keyterm', keyterm)
return url.toString()
}