diff --git a/apps/stage-tamagotchi/package.json b/apps/stage-tamagotchi/package.json index a98857bed..c1e1edffe 100644 --- a/apps/stage-tamagotchi/package.json +++ b/apps/stage-tamagotchi/package.json @@ -19,6 +19,7 @@ "@formkit/auto-animate": "^0.8.2", "@huggingface/transformers": "^3.6.0", "@moeru/std": "catalog:", + "@proj-airi/audio": "workspace:^", "@proj-airi/ccc": "workspace:^", "@proj-airi/drizzle-duckdb-wasm": "catalog:", "@proj-airi/i18n": "workspace:*", diff --git a/apps/stage-web/package.json b/apps/stage-web/package.json index 4010da9e7..f24a41245 100644 --- a/apps/stage-web/package.json +++ b/apps/stage-web/package.json @@ -21,6 +21,7 @@ "@huggingface/transformers": "^3.6.0", "@llama-flow/core": "^0.4.4", "@moeru/std": "catalog:", + "@proj-airi/audio": "workspace:^", "@proj-airi/ccc": "workspace:^", "@proj-airi/drizzle-duckdb-wasm": "catalog:", "@proj-airi/i18n": "workspace:*", diff --git a/packages/audio/package.json b/packages/audio/package.json new file mode 100644 index 000000000..6c02dcc9d --- /dev/null +++ b/packages/audio/package.json @@ -0,0 +1,62 @@ +{ + "name": "@proj-airi/audio", + "type": "module", + "private": true, + "description": "Audio processing utilities for Project AIRI", + "author": { + "name": "Moeru AI Project AIRI Team", + "email": "airi@moeru.ai", + "url": "https://github.com/moeru-ai" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/moeru-ai/airi.git", + "directory": "packages/audio" + }, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./audio-context": { + "types": "./dist/audio-context/index.d.mts", + "default": "./dist/audio-context/index.mjs" + }, + "./audio-context/processor.worklet": { + "types": "./dist/audio-context/processor.worklet.d.mts", + "default": "./dist/audio-context/processor.worklet.mjs" + }, + "./vue": { + "types": "./dist/vue/index.d.mts", + "default": "./dist/vue/index.mjs" + } + }, + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "files": [ + "README.md", + "dist", + "package.json" + ], + "scripts": { + "dev": "pnpm run build", + "build": "tsdown", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "vue": ">=3" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + }, + "dependencies": { + "@alexanderolsen/libsamplerate-js": "^2.1.2" + }, + "devDependencies": { + "@types/audioworklet": "^0.0.77", + "vite": "^7.0.0" + } +} diff --git a/packages/audio/src/audio-context/index.ts b/packages/audio/src/audio-context/index.ts new file mode 100644 index 000000000..44f1d90f9 --- /dev/null +++ b/packages/audio/src/audio-context/index.ts @@ -0,0 +1,316 @@ +/// + +import LibsamplerateWorkletURL from '@alexanderolsen/libsamplerate-js/dist/libsamplerate.worklet.js?worker&url' + +import ProcessorWorkletURL from './processor.worklet?worker&url' + +let context: AudioContext | undefined +let sampleRate: number = 48000 // High quality base sample rate +let isReady: boolean = false +let error: string = '' +let isInitializing: boolean = false +let workletLoaded: boolean = false + +const activeSources = new Set() +const activeGainNodes = new Set() +const activeAnalyzers = new Set() +const activeWorkletNodes = new Set() + +const listeners = new Set<(state: State) => void>() + +export interface State { + isReady: boolean + sampleRate: number + error: string + isInitializing: boolean + workletLoaded: boolean + currentTime: number + state: AudioContextState +} + +export interface WorkletOptions { + inputSampleRate?: number + outputSampleRate?: number + channels?: number + converterType?: number + bufferSize?: number +} + +function notifyListeners() { + const state: State = { + isReady, + sampleRate, + error, + isInitializing, + workletLoaded, + currentTime: context?.currentTime ?? 0, + state: context?.state ?? 'closed', + } + listeners.forEach((listener) => { + try { + listener(state) + } + catch (err) { + console.error('AudioContext state listener error:', err) + } + }) +} + +async function loadWorklets() { + if (!context || workletLoaded) + return + + try { + await context.audioWorklet.addModule(ProcessorWorkletURL) + await context.audioWorklet.addModule(LibsamplerateWorkletURL) + + workletLoaded = true + } + catch (err) { + console.error('Failed to load AudioWorklets:', err) + throw new Error(`Worklet loading failed: ${err}`) + } +} + +export async function initializeAudioContext(requestedSampleRate: number = 48000): Promise { + // Use high quality base sample rate + const baseSampleRate = Math.max(requestedSampleRate, 48000) + + if (context && isReady && sampleRate === baseSampleRate && workletLoaded) { + return context + } + + if (isInitializing) { + return new Promise((resolve, reject) => { + const checkReady = () => { + if (!isInitializing) { + if (context && isReady && workletLoaded) { + resolve(context) + } + else { + reject(new Error(error || 'AudioContext initialization failed')) + } + } + else { + setTimeout(checkReady, 10) + } + } + checkReady() + }) + } + + isInitializing = true + error = '' + notifyListeners() + + try { + // Close existing context if sample rate changed + if (context && sampleRate !== baseSampleRate) { + await cleanupAudioContext() + } + + // Create new context if needed + if (!context) { + context = new AudioContext({ sampleRate: baseSampleRate }) + sampleRate = baseSampleRate + } + + // Resume if suspended + if (context.state === 'suspended') { + await context.resume() + } + + // Load worklets + await loadWorklets() + + isReady = true + notifyListeners() + return context + } + catch (err) { + error = err instanceof Error ? err.message : String(err) + isReady = false + workletLoaded = false + notifyListeners() + console.error('Failed to initialize AudioContext:', err) + throw err + } + finally { + isInitializing = false + notifyListeners() + } +} + +export function createAudioSource(mediaStream: MediaStream): MediaStreamAudioSourceNode { + if (!context || !isReady) { + throw new Error('AudioContext not initialized') + } + + const source = context.createMediaStreamSource(mediaStream) + activeSources.add(source) + return source +} + +export function createAudioAnalyser(options?: Partial<{ + fftSize: number + smoothingTimeConstant: number + minDecibels: number + maxDecibels: number +}>): AnalyserNode { + if (!context || !isReady) { + throw new Error('AudioContext not initialized') + } + + const analyser = context.createAnalyser() + + if (options?.fftSize) + analyser.fftSize = options.fftSize + if (options?.smoothingTimeConstant !== undefined) { + analyser.smoothingTimeConstant = options.smoothingTimeConstant + } + if (options?.minDecibels !== undefined) + analyser.minDecibels = options.minDecibels + if (options?.maxDecibels !== undefined) + analyser.maxDecibels = options.maxDecibels + + activeAnalyzers.add(analyser) + return analyser +} + +export function createAudioGainNode(initialGain: number = 1): GainNode { + if (!context || !isReady) { + throw new Error('AudioContext not initialized') + } + + const gainNode = context.createGain() + gainNode.gain.value = initialGain + activeGainNodes.add(gainNode) + return gainNode +} + +export function removeAudioSource(source: MediaStreamAudioSourceNode) { + source.disconnect() + activeSources.delete(source) +} + +export function removeAudioGainNode(gainNode: GainNode) { + gainNode.disconnect() + activeGainNodes.delete(gainNode) +} + +export function removeAudioAnalyser(analyser: AnalyserNode) { + analyser.disconnect() + activeAnalyzers.delete(analyser) +} + +export async function suspendAudioContext() { + if (context && context.state === 'running') { + await context.suspend() + notifyListeners() + } +} + +export async function resumeAudioContext() { + if (context && context.state === 'suspended') { + await context.resume() + notifyListeners() + } +} + +export function createResamplingWorkletNode( + inputNode: AudioNode, + options: WorkletOptions = {}, +): AudioWorkletNode { + if (!context || !isReady || !workletLoaded) { + throw new Error('AudioContext or worklets not ready') + } + + const workletOptions = { + inputSampleRate: sampleRate, + outputSampleRate: 16000, + channels: 1, + converterType: 2, // SRC_SINC_MEDIUM_QUALITY + bufferSize: 4096, + ...options, + } + + const workletNode = new AudioWorkletNode(context, 'resampling-processor', { + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: workletOptions.channels, + processorOptions: workletOptions, + }) + + // Connect input to worklet + inputNode.connect(workletNode) + + activeWorkletNodes.add(workletNode) + + return workletNode +} + +export function removeWorkletNode(node: AudioWorkletNode) { + node.disconnect() + activeWorkletNodes.delete(node) +} + +export async function cleanupAudioContext() { + // Disconnect all active nodes + activeSources.forEach(source => source.disconnect()) + activeGainNodes.forEach(gainNode => gainNode.disconnect()) + activeAnalyzers.forEach(analyser => analyser.disconnect()) + activeWorkletNodes.forEach(worklet => worklet.disconnect()) + + // Clear sets + activeSources.clear() + activeGainNodes.clear() + activeAnalyzers.clear() + activeWorkletNodes.clear() + + // Close context + if (context && context.state !== 'closed') { + await context.close() + } + + context = undefined + isReady = false + workletLoaded = false + error = '' + notifyListeners() +} + +export function getAudioContextState(): State { + return { + isReady, + sampleRate, + error, + isInitializing, + workletLoaded, + currentTime: context?.currentTime ?? 0, + state: context?.state ?? 'closed', + } +} + +export function getAudioContext(): AudioContext | undefined { + return context +} + +export function getCurrentTime(): number { + return context?.currentTime ?? 0 +} + +export function isAudioContextReady(): boolean { + return isReady +} + +// Event subscription +export function subscribeToAudioContext(listener: (state: State) => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +// Browser cleanup +if ('window' in globalThis && globalThis.window != null) { + globalThis.window.addEventListener('beforeunload', cleanupAudioContext) + globalThis.window.addEventListener('pagehide', cleanupAudioContext) +} diff --git a/packages/audio/src/audio-context/processor.worklet.ts b/packages/audio/src/audio-context/processor.worklet.ts new file mode 100644 index 000000000..3442b3a40 --- /dev/null +++ b/packages/audio/src/audio-context/processor.worklet.ts @@ -0,0 +1,160 @@ +/// + +import type { ConverterTypeValue } from '@alexanderolsen/libsamplerate-js/dist/converter-type' + +import { ConverterType, create } from '@alexanderolsen/libsamplerate-js' + +interface ProcessorOptions { + inputSampleRate: number + outputSampleRate: number + channels: number + converterType: ConverterTypeValue + bufferSize: number +} + +class ResamplingAudioWorkletProcessor extends AudioWorkletProcessor { + private converter: Awaited> | null = null + private isInitialized = false + private options: ProcessorOptions + private inputBuffer: Float32Array[] = [] + private outputBuffer: Float32Array[] = [] + private bufferSize: number + + constructor(options: AudioWorkletNodeOptions) { + super() + + this.options = { + inputSampleRate: options.processorOptions?.inputSampleRate || 44100, + outputSampleRate: options.processorOptions?.outputSampleRate || 16000, + channels: options.processorOptions?.channels || 1, + converterType: options.processorOptions?.converterType || ConverterType.SRC_SINC_MEDIUM_QUALITY, + bufferSize: options.processorOptions?.bufferSize || 4096, + } + + this.bufferSize = this.options.bufferSize + + // Initialize input/output buffers for each channel + for (let i = 0; i < this.options.channels; i++) { + this.inputBuffer[i] = new Float32Array(this.bufferSize) + this.outputBuffer[i] = new Float32Array(0) + } + + this.initializeConverter() + + // Listen for messages from main thread + this.port.onmessage = (event) => { + if (event.data.type === 'updateOptions') { + this.updateOptions(event.data.options) + } + } + } + + private async initializeConverter() { + try { + this.converter = await create( + this.options.channels, + this.options.inputSampleRate, + this.options.outputSampleRate, + { + converterType: this.options.converterType, + }, + ) + this.isInitialized = true + this.port.postMessage({ type: 'initialized', success: true }) + } + catch (error) { + console.error('Failed to initialize sample rate converter:', error) + + this.port.postMessage({ + type: 'initialized', + success: false, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + private async updateOptions(newOptions: Partial) { + const needsReinitialize + = newOptions.inputSampleRate !== this.options.inputSampleRate + || newOptions.outputSampleRate !== this.options.outputSampleRate + || newOptions.channels !== this.options.channels + || newOptions.converterType !== this.options.converterType + + Object.assign(this.options, newOptions) + + if (needsReinitialize && this.converter) { + this.converter.destroy() + this.converter = null + this.isInitialized = false + await this.initializeConverter() + } + } + + process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean { + const input = inputs[0] + const output = outputs[0] + + if (!this.isInitialized || !this.converter || !input.length) { + // Pass through if not ready + for (let channel = 0; channel < output.length; channel++) { + if (input[channel]) { + output[channel].set(input[channel]) + } + } + return true + } + + try { + // Process each channel + for (let channel = 0; channel < Math.min(input.length, this.options.channels); channel++) { + const inputData = input[channel] + + if (inputData && inputData.length > 0) { + // Resample the input data + const resampledData = this.converter.simple(inputData) + + // Send resampled data to main thread + this.port.postMessage({ + type: 'audioData', + channel, + data: resampledData, + originalSampleRate: this.options.inputSampleRate, + outputSampleRate: this.options.outputSampleRate, + timestamp: currentTime, + }) + + // Copy to output (you might want to buffer this properly for different sample rates) + if (output[channel]) { + const copyLength = Math.min(resampledData.length, output[channel].length) + for (let i = 0; i < copyLength; i++) { + output[channel][i] = resampledData[i] + } + // Zero-pad remaining + for (let i = copyLength; i < output[channel].length; i++) { + output[channel][i] = 0 + } + } + } + } + } + catch (error) { + console.error('Resampling error in worklet:', error) + + this.port.postMessage({ + type: 'error', + error: error instanceof Error ? error.message : String(error), + }) + + // Pass through original data on error + for (let channel = 0; channel < output.length; channel++) { + if (input[channel]) { + output[channel].set(input[channel]) + } + } + } + + return true + } +} + +registerProcessor('resampling-processor', ResamplingAudioWorkletProcessor) diff --git a/packages/audio/src/index.ts b/packages/audio/src/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/audio/src/vue/audio-playback.ts b/packages/audio/src/vue/audio-playback.ts new file mode 100644 index 000000000..2aa07d76c --- /dev/null +++ b/packages/audio/src/vue/audio-playback.ts @@ -0,0 +1,94 @@ +import type { MaybeRefOrGetter } from 'vue' + +import { computed, onUnmounted, ref, toRef, watch } from 'vue' + +import { createAudioGainNode, createAudioSource, getAudioContext, getCurrentTime, initializeAudioContext, removeAudioGainNode, removeAudioSource } from '../audio-context' + +export function useAudioPlayback(src: MaybeRefOrGetter) { + const audioContext = getAudioContext() + const sourceStream = toRef(src) + + const isEnabled = ref(false) + const volume = ref(50) // 0-100 + const error = ref('') + + // Audio nodes for playback + const source = ref() + const gainNode = ref() + + const actualVolume = computed(() => volume.value / 100) + + async function setupPlayback() { + if (!sourceStream.value || !isEnabled.value) + return + + try { + error.value = '' + + // Ensure audio context is ready + await initializeAudioContext() + + // Clean up existing playback + cleanupPlayback() + + // Create new nodes + source.value = createAudioSource(sourceStream.value) + gainNode.value = createAudioGainNode(actualVolume.value) + + // Connect: source -> gain -> destination + source.value.connect(gainNode.value) + gainNode.value.connect(audioContext!.destination) + } + catch (err) { + error.value = err instanceof Error ? err.message : String(err) + console.error('Failed to setup audio playback:', err) + } + } + + function cleanupPlayback() { + if (source.value) { + removeAudioSource(source.value) + source.value = undefined + } + + if (gainNode.value) { + removeAudioGainNode(gainNode.value) + gainNode.value = undefined + } + } + + // Watch for changes + watch([sourceStream, isEnabled], async () => { + if (isEnabled.value && sourceStream.value) { + await setupPlayback() + } + else { + cleanupPlayback() + } + }) + + watch(actualVolume, (newVolume) => { + if (gainNode.value) { + // Smooth volume changes to prevent clicks + const currentTime = getCurrentTime() + gainNode.value.gain.cancelScheduledValues(currentTime) + gainNode.value.gain.setValueAtTime(gainNode.value.gain.value, currentTime) + gainNode.value.gain.linearRampToValueAtTime(newVolume, currentTime + 0.1) + } + }) + + onUnmounted(() => { + cleanupPlayback() + }) + + return { + // State + isEnabled, + volume, + error, + + // Manual control + setupPlayback, + cleanupPlayback, + } +} diff --git a/packages/audio/src/vue/audio-stream.ts b/packages/audio/src/vue/audio-stream.ts new file mode 100644 index 000000000..f06f89543 --- /dev/null +++ b/packages/audio/src/vue/audio-stream.ts @@ -0,0 +1,194 @@ +import type { MaybeRefOrGetter } from 'vue' + +import { onUnmounted, ref, toRef } from 'vue' + +import { createAudioAnalyser, createAudioSource, getAudioContext, getCurrentTime, initializeAudioContext } from '../audio-context' + +export interface AudioStreamConfig { + deviceId: string + sampleRate?: number + echoCancellation?: boolean + noiseSuppression?: boolean + autoGainControl?: boolean +} + +export interface AudioAnalysisData { + volumeLevel: number + frequencyData: Uint8Array + timeDomainData: Float32Array + timestamp: number +} + +export type AudioAnalysisCallback = (data: AudioAnalysisData) => void +export type AudioChunkCallback = (chunk: Float32Array, sampleRate: number) => void + +export function useAudioStream(cfg: MaybeRefOrGetter) { + const config = toRef(cfg) + + const isActive = ref(false) + const error = ref('') + const isInitializing = ref(false) // Add loading state + + // Stream-specific state + const mediaStream = ref() + const source = ref() + const analyser = ref() + + // Callbacks for different types of analysis + const analysisCallbacks = new Set() + const chunkCallbacks = new Set() + + // Audio data arrays + const dataArray = ref() + const timeDataArray = ref() + + // Helper function to wait for audio context + async function waitForAudioContext(maxRetries = 10, delay = 100) { + for (let i = 0; i < maxRetries; i++) { + const audioContext = getAudioContext() + if (audioContext && audioContext.state !== 'closed') { + return audioContext + } + await new Promise(resolve => setTimeout(resolve, delay)) + } + throw new Error('Audio context failed to initialize within timeout') + } + + async function start() { + if (isActive.value || isInitializing.value) + return + + try { + isInitializing.value = true + error.value = '' + + // Initialize global audio context and wait for it + await initializeAudioContext(config.value?.sampleRate || 16000) + await waitForAudioContext() + + // Get user media + mediaStream.value = await navigator.mediaDevices.getUserMedia({ + audio: { + deviceId: config.value?.deviceId, + sampleRate: config.value?.sampleRate || 16000, + echoCancellation: config.value?.echoCancellation ?? true, + noiseSuppression: config.value?.noiseSuppression ?? true, + autoGainControl: config.value?.autoGainControl ?? true, + }, + }) + + // Ensure we still have a valid audio context after getUserMedia + const currentAudioContext = getAudioContext() + if (!currentAudioContext) { + throw new Error('Audio context became unavailable') + } + + // Create audio nodes using global context + source.value = createAudioSource(mediaStream.value) + analyser.value = createAudioAnalyser({ + fftSize: 512, + smoothingTimeConstant: 0.1, + }) + + // Connect nodes + source.value.connect(analyser.value) + + // Setup data arrays + const bufferLength = analyser.value.frequencyBinCount + dataArray.value = new Uint8Array(bufferLength) + timeDataArray.value = new Float32Array(analyser.value.fftSize) + + isActive.value = true + startAnalysis() + } + catch (err) { + error.value = err instanceof Error ? err.message : String(err) + console.error('Failed to start audio stream:', err) + } + finally { + isInitializing.value = false + } + } + + function startAnalysis() { + const analyze = () => { + const audioContext = getAudioContext() + if (!audioContext || !analyser.value || !dataArray.value || !timeDataArray.value) + return + + // Get frequency and time domain data + analyser.value.getByteFrequencyData(dataArray.value) + analyser.value.getFloatTimeDomainData(timeDataArray.value) + + // Calculate volume level + let sum = 0 + for (let i = 0; i < dataArray.value.length; i++) { + sum += dataArray.value[i] * dataArray.value[i] + } + const rms = Math.sqrt(sum / dataArray.value.length) + const volumeLevel = Math.min(100, (rms / 255) * 100 * 3) + + // Create analysis data + const analysisData: AudioAnalysisData = { + volumeLevel, + frequencyData: new Uint8Array(dataArray.value), + timeDomainData: new Float32Array(timeDataArray.value), + timestamp: getCurrentTime(), + } + + // Notify analysis callbacks + analysisCallbacks.forEach((callback) => { + try { + callback(analysisData) + } + catch (err) { + console.error('Audio analysis callback error:', err) + } + }) + + // Notify chunk callbacks with fresh audioContext reference + chunkCallbacks.forEach((callback) => { + try { + callback( + new Float32Array(timeDataArray.value), + audioContext.sampleRate, + ) + } + catch (err) { + console.error('Audio chunk callback error:', err) + } + }) + } + + analyze() + } + + // Callback management + function addAnalysisCallback(callback: AudioAnalysisCallback) { + analysisCallbacks.add(callback) + return () => analysisCallbacks.delete(callback) + } + + function addChunkCallback(callback: AudioChunkCallback) { + chunkCallbacks.add(callback) + return () => chunkCallbacks.delete(callback) + } + + // Cleanup + onUnmounted(() => { + stop() + }) + + return { + isActive, + error, + mediaStream, + isInitializing, + + start, + stop, + + addAnalysisCallback, + addChunkCallback, + } +} diff --git a/packages/audio/src/vue/index.ts b/packages/audio/src/vue/index.ts new file mode 100644 index 000000000..bb02a4ede --- /dev/null +++ b/packages/audio/src/vue/index.ts @@ -0,0 +1,2 @@ +export * from './audio-playback' +export * from './audio-stream' diff --git a/packages/audio/tsconfig.json b/packages/audio/tsconfig.json new file mode 100644 index 000000000..f2bd0fd10 --- /dev/null +++ b/packages/audio/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "ESNext", + "DOM" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "types": [ + "@types/audioworklet" + ], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/audio/tsdown.config.ts b/packages/audio/tsdown.config.ts new file mode 100644 index 000000000..40a2ccb7f --- /dev/null +++ b/packages/audio/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { + 'index': 'src/index.ts', + 'audio-context/index': 'src/audio-context/index.ts', + 'audio-context/processor.worklet': 'src/audio-context/processor.worklet.ts', + 'vue/index': 'src/vue/index.ts', + }, + unbundle: true, + fixedExtension: true, + external: [ + '@alexanderolsen/libsamplerate-js/dist/libsamplerate.worklet.js?worker&url', + './processor.worklet?worker&url', + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88d8ad2bd..5b7d56639 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -273,6 +273,9 @@ importers: '@moeru/std': specifier: 'catalog:' version: 0.1.0-beta.4 + '@proj-airi/audio': + specifier: workspace:^ + version: link:../../packages/audio '@proj-airi/ccc': specifier: workspace:^ version: link:../../packages/ccc @@ -562,6 +565,9 @@ importers: '@moeru/std': specifier: 'catalog:' version: 0.1.0-beta.4 + '@proj-airi/audio': + specifier: workspace:^ + version: link:../../packages/audio '@proj-airi/ccc': specifier: workspace:^ version: link:../../packages/ccc @@ -861,6 +867,22 @@ importers: specifier: ^3.5.17 version: 3.5.17(typescript@5.8.3) + packages/audio: + dependencies: + '@alexanderolsen/libsamplerate-js': + specifier: ^2.1.2 + version: 2.1.2 + vue: + specifier: '>=3' + version: 3.5.17(typescript@5.8.3) + devDependencies: + '@types/audioworklet': + specifier: ^0.0.77 + version: 0.0.77 + vite: + specifier: ^7.0.0 + version: 7.0.0(@types/node@24.0.7)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) + packages/ccc: dependencies: meta-png: @@ -877,7 +899,7 @@ importers: devDependencies: unplugin-yaml: specifier: ^3.0.0 - version: 3.0.0(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.44.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.44.1))(astro@5.10.1(@types/node@24.0.7)(encoding@0.1.13)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(rollup@4.44.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(esbuild@0.25.5)(rolldown@1.0.0-beta.20)(rollup@4.44.1)(vite@6.3.5(@types/node@24.0.7)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + version: 3.0.0(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.44.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.44.1))(astro@5.10.1(@types/node@24.0.7)(encoding@0.1.13)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(rollup@4.44.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(esbuild@0.25.5)(rolldown@1.0.0-beta.20)(rollup@4.44.1)(vite@7.0.0(@types/node@24.0.7)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) packages/memory-pgvector: dependencies: @@ -1653,6 +1675,9 @@ packages: resolution: {integrity: sha512-nznEC1ZA/m3hQDEnrGQ4c5gkaa9pcaVnw4LFJyzBAaR7E3nfiAPEHS3otnSafpZouVnoKeITl5D+2LsnwlnK8g==} engines: {node: '>=14.0.0'} + '@alexanderolsen/libsamplerate-js@2.1.2': + resolution: {integrity: sha512-pIXQDX/DZIgz6pKInUddDd6Tnq/s2E9g4ZITkKX60kxM/nAuZcxYa/z0y/jwJbjyp/oKe+8/qHLSqMzQ18xSiQ==} + '@alvarosabu/utils@3.2.0': resolution: {integrity: sha512-aoGWRfaQjOo9TUwrBA6W0zwTHktgrXy69GIFNILT4gHsqscw6+X8P6uoSlZVQFr887SPm8x3aDin5EBVq8y4pw==} @@ -11501,6 +11526,46 @@ packages: yaml: optional: true + vite@7.0.0: + resolution: {integrity: sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitefu@1.0.7: resolution: {integrity: sha512-eRWXLBbJjW3X5z5P5IHcSm2yYbYRPb2kQuc+oqsbAl99WB5kVsPbiiox+cymo8twTzifA6itvhr2CmjnaZZp0Q==} peerDependencies: @@ -12049,6 +12114,8 @@ snapshots: '@akryum/tinypool@0.3.1': {} + '@alexanderolsen/libsamplerate-js@2.1.2': {} + '@alvarosabu/utils@3.2.0': {} '@ampproject/remapping@2.3.0': @@ -23982,7 +24049,7 @@ snapshots: rollup: 4.44.1 vite: rolldown-vite@6.3.21(@types/node@24.0.7)(esbuild@0.25.5)(jiti@2.4.2)(less@4.3.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) - unplugin-yaml@3.0.0(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.44.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.44.1))(astro@5.10.1(@types/node@24.0.7)(encoding@0.1.13)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(rollup@4.44.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(esbuild@0.25.5)(rolldown@1.0.0-beta.20)(rollup@4.44.1)(vite@6.3.5(@types/node@24.0.7)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)): + unplugin-yaml@3.0.0(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.44.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.44.1))(astro@5.10.1(@types/node@24.0.7)(encoding@0.1.13)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(rollup@4.44.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(esbuild@0.25.5)(rolldown@1.0.0-beta.20)(rollup@4.44.1)(vite@7.0.0(@types/node@24.0.7)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)): dependencies: '@rollup/pluginutils': 5.2.0(rollup@4.44.1) unplugin: 2.3.5 @@ -23994,7 +24061,7 @@ snapshots: esbuild: 0.25.5 rolldown: 1.0.0-beta.20 rollup: 4.44.1 - vite: 6.3.5(@types/node@24.0.7)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) + vite: 7.0.0(@types/node@24.0.7)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) unplugin@1.16.1: dependencies: @@ -24349,6 +24416,24 @@ snapshots: tsx: 4.20.3 yaml: 2.8.0 + vite@7.0.0(@types/node@24.0.7)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0): + dependencies: + esbuild: 0.25.5 + fdir: 6.4.6(picomatch@4.0.2) + picomatch: 4.0.2 + postcss: 8.5.6 + rollup: 4.44.1 + tinyglobby: 0.2.14 + optionalDependencies: + '@types/node': 24.0.7 + fsevents: 2.3.3 + jiti: 2.4.2 + less: 4.3.0 + lightningcss: 1.30.1 + terser: 5.43.1 + tsx: 4.20.3 + yaml: 2.8.0 + vitefu@1.0.7(vite@6.3.5(@types/node@24.0.7)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)): optionalDependencies: vite: 6.3.5(@types/node@24.0.7)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)