feat(mobile): bind device identities and bound voice usage in 0.1.4 dev

This commit is contained in:
yilsem committed 2026-09-12 12:11:18 +03:00
1 parent ac6687181e
commit 5f75dcc99a
24 files changed
+909 -47

No files matched your search

+30 -7
View File
@@ -1,4 +1,5 @@
import { ASSISTANT_INSTRUCTION, VOICE_TOOLS, VoiceDispatchGate, taskText } from './behavior.mjs'
import { VoiceUsage, VOICE_PRICING } from './voice-usage.mjs'
export function pcm16(samples, sampleRate) {
const length = Math.floor(samples.length * 16000 / sampleRate)
@@ -12,16 +13,23 @@ export function pcm16(samples, sampleRate) {
}
export class VoiceSession {
constructor({ token, model, onText, onDraft, onSubmit, onError, onState }) {
Object.assign(this, { token, model, onText, onDraft, onSubmit, onError, onState })
constructor({ token, model, onText, onDraft, onSubmit, onError, onState, onUsage, limits, now = () => performance.now() }) {
Object.assign(this, { token, model, onText, onDraft, onSubmit, onError, onState, onUsage, now })
this.usage = new VoiceUsage(model, limits)
this.sources = new Set(); this.stopped = false; this.nextPlayback = 0
this.dispatchGate = new VoiceDispatchGate(); this.receiveChain = Promise.resolve()
this.audioTranscript = ''; this.preparedDraft = null
}
async start(microphone = true) {
if (this.stopped) return
if (this.model !== VOICE_PRICING.model) throw new Error('Bu ses modelinin maliyet tarifesi doğrulanmadı; bağlantı açılmadı.')
this.startedAt = this.now(); this.lastUsageAt = this.startedAt
this.usageTimer = setInterval(() => this.checkLimits(), 1000)
this.checkLimits()
try {
this.context = new AudioContext()
await this.context.resume()
if (this.stopped) return
if (microphone) {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } })
if (this.stopped) { this.stream.getTracks().forEach(t => t.stop()); return }
@@ -45,11 +53,21 @@ export class VoiceSession {
this.socket.onmessage = event => { this.receiveChain = this.receiveChain.then(() => this.receive(event)).catch(() => { this.onError('Ses yanıtı işlenemedi.'); this.stop() }) }
this.socket.onerror = () => { this.onError('Google ses bağlantısı kurulamadı. Anahtar, model erişimi ve kotayı kontrol edin.'); this.stop() }
this.socket.onclose = () => this.stop()
this.limit = setTimeout(() => { this.onText('bilgi', 'Bu görüşmenin 10 dakikalık yerel süre sınırı doldu. Bu bir fatura üst sınırı değildir.'); this.stop() }, 10 * 60000)
this.setupTimer = setTimeout(() => { if (!this.ready) { this.onError('Google ses başlangıcı zaman aşımına uğradı.'); this.stop() } }, 20000)
} catch { this.onError('Mikrofon veya ses bağlantısı açılamadı. Android mikrofon iznini kontrol edin; yazıyla devam edebilirsiniz.'); this.stop() }
}
send(frame) { if (this.socket?.readyState === 1) this.socket.send(JSON.stringify(frame)) }
checkLimits() {
if (this.stopped) return
const seconds = Math.floor((this.now() - this.startedAt) / 1000)
const usage = this.usage.snapshot()
this.onUsage?.({ ...usage, seconds })
let reason
if (usage.reached) reason = 'Belirlediğiniz tahmini maliyet sınırına ulaşıldı.'
else if (seconds >= this.usage.limits.minutes * 60) reason = 'Belirlediğiniz yerel görüşme süresi doldu.'
else if (this.now() - this.lastUsageAt >= 120000) reason = 'İki dakikadır Google kullanım bildirimi alınamadı; maliyet izlenemediği için görüşme kapatıldı.'
if (reason) { this.onText?.('bilgi', `${reason} Bu kontrol kesin fatura üst sınırı değildir.`); this.stop(reason) }
}
send(frame) { if (!this.stopped && this.socket?.readyState === 1) this.socket.send(JSON.stringify(frame)) }
text(text) {
if (!this.ready) throw new Error('Ses asistanı bağlantısı henüz hazır değil.')
this.interruptPlayback()
@@ -69,6 +87,11 @@ export class VoiceSession {
async receive(event) {
const data = JSON.parse(event.data instanceof Blob ? await event.data.text() : event.data)
if (this.stopped) return
if (data.usageMetadata) {
try { this.usage.add(data.usageMetadata); this.lastUsageAt = this.now(); this.checkLimits() }
catch { this.onError?.('Google kullanım bilgisi hesaplanamadı; maliyet takibi güvenilir olmadığı için ses görüşmesi kapatıldı.'); this.stop(); return }
if (this.stopped) return
}
if (data.setupComplete) { this.ready = true; clearTimeout(this.setupTimer); this.onState('Google asistanı bağlı.'); return }
if (data.error) { this.onError('Google ses isteğini reddetti. AI Studio erişim ve kotanızı kontrol edin.'); this.stop(); return }
const content = data.serverContent
@@ -116,12 +139,12 @@ export class VoiceSession {
source.start(start); this.nextPlayback = start + buffer.duration
}
interruptPlayback() { for (const source of this.sources) { try { source.stop() } catch {} }; this.sources.clear(); this.nextPlayback = 0 }
stop() {
stop(reason = '') {
if (this.stopped) return
this.stopped = true; this.ready = false; clearTimeout(this.limit); clearTimeout(this.setupTimer)
this.stopped = true; this.ready = false; clearInterval(this.usageTimer); clearTimeout(this.setupTimer)
this.dispatchGate.invalidate()
this.interruptPlayback(); this.stream?.getTracks().forEach(t => t.stop())
this.input?.disconnect(); this.capture?.disconnect(); this.silence?.disconnect()
this.socket?.close(); void this.context?.close(); this.onState('Ses görüşmesi kapalı. IDE işi devam edebilir.')
this.socket?.close(); void this.context?.close(); this.onState?.(`${reason ? reason + ' ' : ''}Ses görüşmesi kapalı. IDE işi devam edebilir.`)
}
}