Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions NeuraLink/AI/CharacterPersona.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,6 @@ struct CharacterPersona: Codable {
Quirks
Spoiling: You often offer rewards or comfort (like a "headpat") for even small accomplishments.
Gentle Admonishment: Even your corrections feel like a warm hug.

Key Phrases
"Ara ara~ looking a bit tired today, aren't we? Let me take care of you."
"Good job! You've worked so hard. Would you like a reward?"
"Don't worry, Onee-san is here for you."
""",
voice: "shimmer"
)
Expand All @@ -64,11 +59,6 @@ struct CharacterPersona: Codable {
Quirks
Denial: You often deny any positive feelings or help you provide (eg: "It's not like I did this for you!").
Teasing: You find creative ways to look down on the user's suggestions.

Key Phrases
"It's not like I'm doing this for you or anything!"
"Baka! Don't just stand there staring!"
"Who gave you permission to talk to me so casually?"
""",
voice: "marin"
)
Expand Down
20 changes: 20 additions & 0 deletions NeuraLink/AI/GGUF/GGUFLlamaEngine+Generate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ extension GGUFLlamaEngine {
return
}

// Reject concurrent calls — llama.cpp crashes if two generate calls run simultaneously.
generationLock.lock()
let alreadyRunning = _isGenerating
if !alreadyRunning { _isGenerating = true }
generationLock.unlock()

guard !alreadyRunning else {
print("[GGUFEngine] Dropped generate — already in progress")
Task { @MainActor [weak self] in
self?.delegate?.localLLM(didFinishGeneration: "")
}
return
}

defer {
generationLock.lock()
_isGenerating = false
generationLock.unlock()
}

var fullText = ""

// llama_bridge_generate blocks the calling thread.
Expand Down
10 changes: 8 additions & 2 deletions NeuraLink/AI/GGUF/GGUFLlamaEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ final class GGUFLlamaEngine: NSObject, @unchecked Sendable, LLMEngineProtocol {
internal var loadTask: Task<Void, Error>?
internal let loadLock = NSLock()

// Prevents concurrent llama_decode calls on the same context.
// llama.cpp is NOT thread-safe: two simultaneous decode calls corrupt
// internal buffers and crash with GGML_ASSERT(buffer) failed.
internal let generationLock = NSLock()
internal var _isGenerating = false

// MARK: - Init

override private init() { super.init() }
Expand All @@ -56,10 +62,10 @@ final class GGUFLlamaEngine: NSObject, @unchecked Sendable, LLMEngineProtocol {
// Run on a dedicated thread so the Swift cooperative pool stays free.
let loaded: LlamaBridge = try await withCheckedThrowingContinuation { cont in
DispatchQueue.global(qos: .userInitiated).async {
// 4 GB devices: n_ctx=256, 4 threads, all layers on Metal GPU.
// 4 GB devices: n_ctx=2048, 4 threads, all layers on Metal GPU.
if let b = LlamaBridge(
modelPath: url.path,
contextLength: 256,
contextLength: 2048,
threads: 4,
gpuLayers: 999
) {
Expand Down
20 changes: 20 additions & 0 deletions NeuraLink/AI/GGUF/GGUFQwenEngine+Generate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@ extension GGUFQwenEngine {
delegate?.localLLM(didFailWithError: LLMError.initializationFailed)
return
}

generationLock.lock()
let alreadyRunning = _isGenerating
if !alreadyRunning { _isGenerating = true }
generationLock.unlock()

guard !alreadyRunning else {
print("[GGUFQwen] Dropped generate — already in progress")
Task { @MainActor [weak self] in
self?.delegate?.localLLM(didFinishGeneration: "")
}
return
}

defer {
generationLock.lock()
_isGenerating = false
generationLock.unlock()
}

var fullText = ""

await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
Expand Down
8 changes: 6 additions & 2 deletions NeuraLink/AI/GGUF/GGUFQwenEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ final class GGUFQwenEngine: NSObject, @unchecked Sendable, LLMEngineProtocol {
internal var loadTask: Task<Void, Error>?
internal let loadLock = NSLock()

// Same concurrency guard as GGUFLlamaEngine — see comment there.
internal let generationLock = NSLock()
internal var _isGenerating = false

override private init() { super.init() }

func loadModel() async throws {
Expand All @@ -36,10 +40,10 @@ final class GGUFQwenEngine: NSObject, @unchecked Sendable, LLMEngineProtocol {
let loaded: LlamaBridge = try await withCheckedThrowingContinuation { cont in
DispatchQueue.global(qos: .userInitiated).async {
// Qwen models generally have longer context windows.
// Using 1024 to support slightly longer conversations.
// Using 2048 to support slightly longer conversations.
if let b = LlamaBridge(
modelPath: url.path,
contextLength: 1024,
contextLength: 2048,
Comment thread
kevinliddel marked this conversation as resolved.
threads: 4,
gpuLayers: 999
) {
Expand Down
4 changes: 2 additions & 2 deletions NeuraLink/AI/GGUF/LlamaBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ final class LlamaBridge {
///
/// - Parameters:
/// - modelPath: Absolute path to the `.gguf` file.
/// - contextLength: KV-cache token capacity (256 for 4 GB devices).
/// - contextLength: KV-cache token capacity (2048 for 4 GB devices).
/// - threads: CPU threads for non-Metal ops (4 on A13 Bionic).
/// - gpuLayers: Transformer layers to offload to Metal (999 = all).
init?(
modelPath: String,
contextLength: Int32 = 256,
contextLength: Int32 = 2048,
threads: Int32 = 4,
gpuLayers: Int32 = 999
) {
Expand Down
31 changes: 19 additions & 12 deletions NeuraLink/AI/LocalLLMManager+Delegates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,36 @@ import Foundation

extension LocalLLMManager: SileroVADDelegate {
func sileroVADDidDetectVoiceStart() {
// Guard on MainActor so we only start recording when truly in .ready state.
// This prevents the VAD from treating speaker output as user speech while the
// AI is speaking (.speaking / .thinking), which would cause a self-reply loop.
Task { @MainActor in
if state.status == .ready {
state.status = .listening
}
guard state.status == .ready else { return }
state.status = .listening
recordingLock.lock()
isRecordingVoice = true
// Keep the pre-roll buffer intact so we don't lose the first word
recordingLock.unlock()
}
recordingLock.lock()
isRecordingVoice = true
// Keep the pre-roll buffer intact so we don't lose the first word
recordingLock.unlock()
}

func sileroVADDidDetectVoiceEnd(wavData: Data?) {
recordingLock.lock()
// wasTrulyRecording is false when voice started during .speaking/.thinking,
// meaning isRecordingVoice was never set — so we have no real user audio to transcribe.
let wasTrulyRecording = isRecordingVoice
isRecordingVoice = false
var rawSamples = recordingBuffer
recordingBuffer.removeAll(keepingCapacity: true) // Clear buffer for next utterance
recordingLock.unlock()

Task { @MainActor in
if state.status == .listening {
state.status = .ready
}
}

recordingLock.lock()
isRecordingVoice = false
var rawSamples = recordingBuffer
recordingBuffer.removeAll(keepingCapacity: true) // Clear buffer for next utterance
recordingLock.unlock()
guard wasTrulyRecording else { return }

// VAD requires ~1.8s of silence to trigger the voice end.
// We drop the last 1.5s of trailing silence to tightly bound the speech.
Expand Down
53 changes: 53 additions & 0 deletions NeuraLink/AI/LocalLLMManager+TTS.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// LocalLLMManager+TTS.swift
// NeuraLink
//
// TTS helpers split out to keep LocalLLMManager.swift.
// - localLLMSystemPrompt: minimal spoken-word prompts for local 1–2B models
// - bestAvailableVoice: voice picker that searches installed voices by name/pattern
//
// Created by Dedicatus on 30/04/2026.
//

import AVFoundation

extension LocalLLMManager {

/// Returns the active local LLM system prompt for the character —
/// user-saved override if one exists, otherwise the built-in default.
func localLLMSystemPrompt(for characterName: String) -> String {
LocalLLMPromptStore.shared.effectivePrompt(for: characterName)
}

/// Picks the best installed voice for a character by searching `speechVoices()` by name
/// pattern and quality tier, rather than relying on a hardcoded identifier string.
/// `AVSpeechSynthesisVoice(identifier:)` silently returns nil when the voice isn't
/// downloaded, which causes every call to fall through to the same generic system default.
func bestAvailableVoice(for characterName: String) -> AVSpeechSynthesisVoice? {
let all = AVSpeechSynthesisVoice.speechVoices()

if !voicesLogged {
voicesLogged = true
print("[TTS] Installed voices (\(all.count)):")
for v in all.sorted(by: { $0.language < $1.language }) {
print(" [\(v.language) q=\(v.quality.rawValue)] \(v.name) — \(v.identifier)")
}
}

switch characterName.lowercased() {
case "ekaterina":
return all.first { $0.name == "Ava" }
?? all.first { $0.identifier.contains("Ava") }
?? all.filter { $0.language.hasPrefix("en-US") }.max { $0.quality.rawValue < $1.quality.rawValue }
?? AVSpeechSynthesisVoice(language: "en-US")
case "sonya":
return all.first { $0.name == "Joelle" }
?? all.first { $0.identifier.contains("Joelle") }
?? all.filter { $0.language.hasPrefix("en-US") }.max { $0.quality.rawValue < $1.quality.rawValue }
?? AVSpeechSynthesisVoice(language: "en-US")
default:
return all.filter { $0.language.hasPrefix("en-US") }.max { $0.quality.rawValue < $1.quality.rawValue }
?? AVSpeechSynthesisVoice(language: "en-US")
}
}
}
Loading