diff --git a/aesel/src/ac-server.mjs b/aesel/src/ac-server.mjs index 5244e5249c..0a67fd73c3 100644 --- a/aesel/src/ac-server.mjs +++ b/aesel/src/ac-server.mjs @@ -496,7 +496,12 @@ export class AcServer extends EventEmitter { } } else if (event.type === "content_block_delta") { const delta = event.delta; - if (delta?.type === "text_delta" && delta.text) { + if (delta?.type === "thinking_delta" && delta.thinking) { + // A reasoning model's hidden work. Not part of the answer and + // never fed back, but a surface that shows the piece being made + // can scroll it rather than sit silent for ten seconds. + this.emit("notification", { method: "item/reasoning/delta", params: { itemId: messageId, delta: delta.thinking } }); + } else if (delta?.type === "text_delta" && delta.text) { text += delta.text; this.emit("notification", { method: "item/agentMessage/delta", diff --git a/aesel/test/ac-server.test.mjs b/aesel/test/ac-server.test.mjs index 32b7227325..f7df3df8e9 100644 --- a/aesel/test/ac-server.test.mjs +++ b/aesel/test/ac-server.test.mjs @@ -376,6 +376,24 @@ test('embedded preview can retain runtime feedback without offering desktop fram } }); +test('thinking deltas surface as reasoning notifications and never enter the answer',async t=>{ + const dir=await mkdtemp(join(tmpdir(),'ac-thinking-'));t.after(()=>rm(dir,{recursive:true,force:true})); + const file=join(dir,'piece.mjs');await writeFile(file,'export function paint(){}'); + const reasoning=[],answers=[]; + const engine=new AcServer({cwd:dir,piece:{file},jev:null,token:()=> 'test',fetch:async()=>serving([ + {type:'content_block_start',index:0,content_block:{type:'thinking',thinking:''}}, + {type:'content_block_delta',index:0,delta:{type:'thinking_delta',thinking:'the berries should vanish one at a time'}}, + {type:'content_block_stop',index:0}, + {type:'content_block_delta',index:1,delta:{type:'text_delta',text:'done'}}, + {type:'message_delta',delta:{stop_reason:'end_turn'}}, + ])()}); + engine.on('notification',({method,params})=>{if(method==='item/reasoning/delta')reasoning.push(params.delta);if(method==='item/agentMessage/delta')answers.push(params.delta);}); + await engine.startTurn('eat them'); + assert.deepEqual(reasoning,['the berries should vanish one at a time']); + assert.deepEqual(answers,['done']); + assert.ok(!JSON.stringify(engine.messages).includes('vanish one at a time'),'thinking is not kept in the conversation'); +}); + test('a reasoning object is forwarded as given, and absent by default',async t=>{ const dir=await mkdtemp(join(tmpdir(),'ac-reasoning-'));t.after(()=>rm(dir,{recursive:true,force:true})); const file=join(dir,'piece.mjs');await writeFile(file,'export function paint(){}'); diff --git a/apple/walkieware/Resources/Web/engine.mjs b/apple/walkieware/Resources/Web/engine.mjs index 197225f9c4..1855436856 100644 --- a/apple/walkieware/Resources/Web/engine.mjs +++ b/apple/walkieware/Resources/Web/engine.mjs @@ -30,7 +30,7 @@ try{lastAttempt=JSON.parse(localStorage.getItem(storageKey+'-attempt')||'null'); let versions=null,turnSucceeded=false,turnRequest='',turnParent=null,turnRuntimeFailed=false,turnError=''; let token = '', busy = false, server, source = '', previous = '', pending = '', checkpoints = 0; let feedback = null, lastPaintedSource = '', previewSource = '', provisional = '', compileTimer = null; -let outputStream='',code = '', codeItem = '', firstDelta = false, started = 0, timer, ready = false, painted = false; +let outputStream='',reasoningStream='',code = '', codeItem = '', firstDelta = false, started = 0, timer, ready = false, painted = false; const events = []; const signIn=document.createElement('button');signIn.id='connect-ac';signIn.textContent='Account';signIn.onclick=()=>post({action:'signIn'});const identity=document.createElement('div');identity.id='walkieware-identity';const codeLabel=document.createElement('span');codeLabel.id='walkieware-thread';identity.append(signIn,codeLabel);document.body.append(identity); let accountToken='',accountHandle='',accountPalette=[]; @@ -216,6 +216,9 @@ function makeServer(){ $('live-details').open=true; } if(method==='item/agentMessage/delta'){delta(params.delta);$('live-request').textContent+=params.delta;} + // Reasoning streams before any code. Let it fly by in the ticker, dimmed by + // the native side, so the wait reads as work rather than silence. + if(method==='item/reasoning/delta'){reasoningStream=(reasoningStream+params.delta).slice(-6000);if(!firstDelta){benchmark('firstReasoning');log('Model thinking');}if(!code)outputStream=reasoningStream;phase('Thinking…');nativeSnapshot();} if(method==='item/started')benchmark('toolStarted',{tool:params.item?.tool||params.item?.type}); if(method==='item/completed' && params.item?.status?.startsWith('failed')) {log(params.item.status);benchmark('toolFailed',{message:params.item.status});} if(method==='turn/usage') log('Usage · '+(params.usage.output_tokens??0)+' output tokens'); @@ -238,7 +241,7 @@ async function ask(text,displayText=text,advice=null,starter=null,localText=text activeAttempt=saveAttempt(localStorage,storageKey,recovered||{id:crypto.randomUUID(),text,displayText,localText,parent:versions.head.id,baseSource:versions.head.source,retries:0,status:'working'}); } catch { phase('Could not save request for recovery');return; } recoveryPending=false; - pending='';busy=true;outputStream='';previous=source;turnStarter='';starterPainted=false;turnCancelled=false;turnSucceeded=false;turnRuntimeFailed=false;turnError='';turnRequest=text;turnParent=versions?.head.id;review(false);firstDelta=false;checkpoints=0;code='';codeItem=''; + pending='';busy=true;outputStream='';reasoningStream='';previous=source;turnStarter='';starterPainted=false;turnCancelled=false;turnSucceeded=false;turnRuntimeFailed=false;turnError='';turnRequest=text;turnParent=versions?.head.id;review(false);firstDelta=false;checkpoints=0;code='';codeItem=''; lastAttempt={request:displayText.slice(0,20000),parent:turnParent,status:'working',startedAt:new Date().toISOString()}; document.body.classList.add('live-mode');phase('Sending…');log('Submitted'); timer=setInterval(()=>$('live-time').textContent=((performance.now()-started)/1000).toFixed(1)+'s',100); diff --git a/apple/walkieware/Sources/CodeTicker.swift b/apple/walkieware/Sources/CodeTicker.swift index fd098398ab..543c37499d 100644 --- a/apple/walkieware/Sources/CodeTicker.swift +++ b/apple/walkieware/Sources/CodeTicker.swift @@ -2,6 +2,8 @@ import SwiftUI struct CodeTicker: View { let output: String + /// Reasoning scrolls dimmed and plain; code is bold and full strength. + var thinking = false @Environment(\.accessibilityReduceMotion) private var reduceMotion private var line: String { String(output.suffix(1800)).replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: " ") @@ -10,7 +12,8 @@ struct CodeTicker: View { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 0) { - Text(line).font(.custom("Courier-Bold", size: 18, relativeTo: .body)) + Text(line).font(.custom(thinking ? "Courier" : "Courier-Bold", size: 18, relativeTo: .body)) + .opacity(thinking ? 0.55 : 1) .lineLimit(1).fixedSize(horizontal: true, vertical: false) Color.clear.frame(width: 1, height: 1).id("latest-code") } @@ -22,6 +25,6 @@ struct CodeTicker: View { } } }.frame(maxWidth: .infinity).clipped() - .accessibilityLabel("Live code").accessibilityIdentifier("code-ticker") + .accessibilityLabel(thinking ? "Model thinking" : "Live code").accessibilityIdentifier("code-ticker") } } diff --git a/apple/walkieware/Sources/SpeechCapture.swift b/apple/walkieware/Sources/SpeechCapture.swift new file mode 100644 index 0000000000..3534387a38 --- /dev/null +++ b/apple/walkieware/Sources/SpeechCapture.swift @@ -0,0 +1,212 @@ +import Foundation +import AVFoundation +import Speech + +// Hold-to-talk capture: the microphone feeds on-device speech recognition and +// the pitch/energy analyzer at once, and a release delivers one utterance. +// This type owns the audio session, the recognizer and the timing rules (the +// eight-second budget, the release tail, the finalize grace). It knows nothing +// about screens or web views: it reports through three closures, and the +// session maps those onto published state and the JavaScript bridge. +@MainActor final class SpeechCapture { + /// listening · partial · sound · musicalObservation · processing · mixedFinal · error + var onEvent: (_ kind: String, _ text: String, _ id: String) -> Void = { _, _, _ in } + /// Latest microphone energy, for the live waveform. + var onLevel: (_ rms: Double) -> Void = { _ in } + /// The audio benchmark fixture finished replaying and wants the release. + var onReplayRelease: () -> Void = {} + + /// The id of the hold in progress, or empty. + private(set) var turn = "" + + private let engine = AVAudioEngine() + private let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")) + private var request: SFSpeechAudioBufferRecognitionRequest? + private var task: SFSpeechRecognitionTask? + private var held = false + private var tapped = false + private var listeningSince: Date? + private var latest = "" + private var musicalInput = MusicalInput() + private var wordSegments: [[String: Any]] = [] + private var delivering = false + private var recognitionFinal = false + private var audioEnded = false + private var replayTask: Task? + private var finishing: Task? + private var deadline: Task? + + private func emit(_ kind: String, text: String = "", id: String? = nil) { + if kind == "listening" { listeningSince = Date() } + onEvent(kind, text, id ?? turn) + } + + func start(_ id: String) { + cancel() + turn = id; held = true; latest = ""; wordSegments = []; delivering = false; recognitionFinal = false; audioEnded = false + #if DEBUG + if NativeScreenFixture.enabled && NativeScreenFixture.mode == "gestures" { emit("listening"); return } + #endif + musicalInput = MusicalInput() + musicalInput.onUpdate = { [weak self] pitch, rms in + Task { @MainActor in + guard let self, self.turn == id, self.held else { return } + self.onLevel(rms.isFinite ? max(0, rms) : 0) + if let pitch, rms > 0.012 { self.emit("sound", text: "\(Int(pitch)) Hz") } + } + } + musicalInput.onObservation = { [weak self] sound in + Task { @MainActor in + guard let self, self.turn == id, self.held else { return } + let value: [String: Any] = ["transcript":self.latest,"words":self.wordSegments,"sound":sound] + guard let data = try? JSONSerialization.data(withJSONObject:value), let json = String(data:data,encoding:.utf8) else { return } + self.emit("musicalObservation", text:json) + } + } + if AudioBenchmark.enabled { AudioBenchmark.reset(); AudioBenchmark.mark("holdStarted") } + Task { [weak self] in + guard let self else { return } + let speech = await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0 == .authorized) } + } + guard self.turn == id, self.held else { return } + guard speech else { self.fail("Speech permission is off. Enable it in Settings."); return } + let microphone = await AVCaptureDevice.requestAccess(for: .audio) + guard self.turn == id, self.held else { return } + guard microphone else { self.fail("Microphone permission is off."); return } + guard let recognizer = self.recognizer, recognizer.isAvailable, + recognizer.supportsOnDeviceRecognition else { + self.fail("On-device English speech is unavailable."); return + } + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .measurement, options: [.defaultToSpeaker, .allowBluetooth]) + try session.setActive(true) + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + request.taskHint = .dictation + request.requiresOnDeviceRecognition = true + request.contextualStrings = ["Walkieware", "Aesthetic Computer", "garden", "flowers"] + self.request = request + if !AudioBenchmark.enabled { + let input = self.engine.inputNode + let format = input.outputFormat(forBus: 0) + guard format.sampleRate > 0, format.channelCount > 0 else { + self.fail("No microphone is available."); return + } + let sound = self.musicalInput + input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in request.append(buffer); sound.feed(buffer) } + self.tapped = true + self.engine.prepare() + try self.engine.start() + } + self.emit("listening") + self.task = recognizer.recognitionTask(with: request) { [weak self] result, error in + let text = result?.bestTranscription.formattedString + let segments = result?.bestTranscription.segments.map { ["text": $0.substring, "atMs": $0.timestamp * 1000, "durationMs": $0.duration * 1000, "confidence": Double($0.confidence)] as [String: Any] } + let isFinal = result?.isFinal ?? false + let message = error?.localizedDescription + Task { @MainActor in + guard let self, self.turn == id else { return } + if let segments { self.wordSegments = Array(segments.prefix(256)) } + if let text { self.latest = text; self.emit("partial", text: text) } + if isFinal { self.recognitionFinal = true; AudioBenchmark.mark("recognitionFinal") } + if self.audioEnded && isFinal { self.deliver() } + else if self.audioEnded && message != nil && self.latest.isEmpty { + self.deliver() // Sound remains useful when speech finds no words. + } + } + } + if AudioBenchmark.enabled { + let sound = self.musicalInput + self.replayTask = Task { [weak self] in + do { + try await AudioBenchmark.replay(into: request, sound: sound) { self?.onReplayRelease() } + } catch { if !Task.isCancelled { self?.fail("Audio fixture replay failed.") } } + } + } + self.deadline = Task { [weak self] in + try? await Task.sleep(for: .seconds(8)) + guard !Task.isCancelled, let self, self.turn == id else { return } + self.stop() + } + } catch { self.fail("Could not start the microphone. Please try again.") } + } + } + + private func stopAudio() { + engine.stop() + if tapped { engine.inputNode.removeTap(onBus: 0); tapped = false } + request?.endAudio() + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + /// The thumb lifted. Ignored when `id` names a hold that is no longer this one. + func stop(matching id: String? = nil) { + if let id, id != turn { return } + guard !turn.isEmpty, held else { return } + held = false + AudioBenchmark.mark("releaseReceived") + guard request != nil else { cancel(); return } + // Capture a release tail, within the eight-second recording budget. A + // thumb lifts a beat before the last word lands; 0.25 s clipped endings. + let elapsed = listeningSince.map { Date().timeIntervalSince($0) } ?? 8 + let tail = min(0.45, max(0, 8 - elapsed)) + emit("processing") + finishing?.cancel() + let id = turn + finishing = Task { [weak self] in + try? await Task.sleep(for: .seconds(tail)) + guard !Task.isCancelled, let self, self.turn == id else { return } + self.stopAudio() + self.audioEnded = true + AudioBenchmark.mark("audioDrained") + if self.recognitionFinal { self.deliver(); return } + // endAudio lets recognition consume every queued buffer and finalize. + // If it never does, the partial transcript is still the person's + // words: send what was heard rather than throw the utterance away. + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled, self.turn == id, !self.delivering else { return } + AudioBenchmark.mark("recognitionTimedOut", fields: ["recognizedCharacters": self.latest.count]) + self.deliver() + } + } + + private func deliver() { + guard !delivering, !turn.isEmpty else { return } + delivering = true + let text = latest.trimmingCharacters(in: .whitespacesAndNewlines) + let id = turn, words = wordSegments + stopAudio() + musicalInput.finish { [weak self] sound in + Task { @MainActor in + guard let self, self.turn == id else { return } + guard !text.isEmpty || (sound["audibleMs"] as? Double ?? 0) >= 150 else { + self.fail("I didn’t hear words or a sound. Hold to try again."); return + } + let value: [String: Any] = ["transcript":text,"words":words,"sound":sound] + guard let data = try? JSONSerialization.data(withJSONObject:value), let json = String(data:data,encoding:.utf8) else { self.fail("Could not read sound input."); return } + AudioBenchmark.checkTranscript(text) + AudioBenchmark.mark("soundSubmitted", fields: value) + self.cancel() + self.emit("mixedFinal", text:json, id:id) + } + } + } + + private func fail(_ message: String) { + AudioBenchmark.mark("recognitionFailed", fields: ["message": message, "recognizedCharacters": latest.count]) + let id = turn + cancel() + emit("error", text: message, id: id) + } + + func cancel() { + turn = ""; held = false; listeningSince = nil + replayTask?.cancel(); replayTask = nil + finishing?.cancel(); finishing = nil + deadline?.cancel(); deadline = nil + stopAudio() + task?.cancel(); task = nil; request = nil + } +} diff --git a/apple/walkieware/Sources/WalkiewareApp.swift b/apple/walkieware/Sources/WalkiewareApp.swift index 4bb62fc180..d85358776d 100644 --- a/apple/walkieware/Sources/WalkiewareApp.swift +++ b/apple/walkieware/Sources/WalkiewareApp.swift @@ -242,26 +242,19 @@ final class WalkiewareSession: NSObject, ObservableObject, WKScriptMessageHandle if recoveryCount < 1 { recoveryCount += 1; reloadWorkspace() } else { startupFailure = "The workspace stopped. Tap Reload to reopen it." } } - private let engine = AVAudioEngine() - private let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")) let account = WalkiewareAccount() + // The microphone, recognizer and release timing live in SpeechCapture; this + // class only turns its events into screen state and bridge messages. + private let capture = SpeechCapture() + override init() { + super.init() + capture.onEvent = { [weak self] kind, text, id in self?.emit(kind, text: text, id: id) } + capture.onLevel = { [weak self] rms in guard let self else { return }; self.microphoneLevels = Array(self.microphoneLevels.dropFirst()) + [rms] } + capture.onReplayRelease = { [weak self] in self?.webView?.evaluateJavaScript("voiceEnd()", completionHandler: nil) } + } private var previewFrame: WKFrameInfo? private var previewSource = "" private var previewThreadID = UUID().uuidString - private var request: SFSpeechAudioBufferRecognitionRequest? - private var task: SFSpeechRecognitionTask? - private var turn = "" - private var held = false - private var tapped = false - private var latest = "" - private var musicalInput = MusicalInput() - private var wordSegments: [[String: Any]] = [] - private var delivering = false - private var recognitionFinal = false - private var audioEnded = false - private var replayTask: Task? - private var finishing: Task? - private var deadline: Task? func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { if !message.frameInfo.isMainFrame, @@ -362,9 +355,12 @@ final class WalkiewareSession: NSObject, ObservableObject, WKScriptMessageHandle guard let source = body["source"] as? String, source.utf8.count < 500_000 else { return } if let id = body["threadID"] as? String, UUID(uuidString: id) != nil { previewThreadID = id } previewSource = source; renderPreview() - case "start": start(id) - case "stop": if id == turn { stop() } - case "cancel": if id == turn { cancel() } + case "start": + capturePhase = .opening; captureError = nil; transcript = "" + microphoneLevels = Array(repeating: 0, count: 28) + capture.start(id) + case "stop": capture.stop(matching: id) + case "cancel": if id == capture.turn { cancel() } case "share": guard let value = body["data"] as? String, value.count < 10_000_000, value.hasPrefix("data:image/png;base64,"), @@ -401,7 +397,7 @@ final class WalkiewareSession: NSObject, ObservableObject, WKScriptMessageHandle } if kind == "partial", !text.isEmpty { AudioBenchmark.mark("firstRecognizedWords") } if kind == "final" { AudioBenchmark.checkTranscript(text) } - let event = ["kind": kind, "text": text, "id": id ?? turn] + let event = ["kind": kind, "text": text, "id": id ?? capture.turn] guard let data = try? JSONSerialization.data(withJSONObject: event), let json = String(data: data, encoding: .utf8) else { return } webView?.evaluateJavaScript("window.walkieNativeEvent?.(\(json))") { _, error in @@ -411,175 +407,8 @@ final class WalkiewareSession: NSObject, ObservableObject, WKScriptMessageHandle } } - private func start(_ id: String) { - cancel() - capturePhase = .opening; captureError = nil; transcript = "" - microphoneLevels = Array(repeating: 0, count: 28) - turn = id; held = true; latest = ""; wordSegments = []; delivering = false; recognitionFinal = false; audioEnded = false - #if DEBUG - if NativeScreenFixture.enabled && NativeScreenFixture.mode == "gestures" { emit("listening"); return } - #endif - musicalInput = MusicalInput() - musicalInput.onUpdate = { [weak self] pitch, rms in - Task { @MainActor in - guard let self, self.turn == id, self.held else { return } - self.microphoneLevels = Array(self.microphoneLevels.dropFirst()) + [rms.isFinite ? max(0, rms) : 0] - if let pitch, rms > 0.012 { self.emit("sound", text: "\(Int(pitch)) Hz") } - } - } - musicalInput.onObservation = { [weak self] sound in - Task { @MainActor in - guard let self, self.turn == id, self.held else { return } - let value: [String: Any] = ["transcript":self.latest,"words":self.wordSegments,"sound":sound] - guard let data = try? JSONSerialization.data(withJSONObject:value), let json = String(data:data,encoding:.utf8) else { return } - self.emit("musicalObservation", text:json) - } - } - if AudioBenchmark.enabled { AudioBenchmark.reset(); AudioBenchmark.mark("holdStarted") } - Task { [weak self] in - guard let self else { return } - let speech = await withCheckedContinuation { continuation in - SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0 == .authorized) } - } - guard self.turn == id, self.held else { return } - guard speech else { self.fail("Speech permission is off. Enable it in Settings."); return } - let microphone = await AVCaptureDevice.requestAccess(for: .audio) - guard self.turn == id, self.held else { return } - guard microphone else { self.fail("Microphone permission is off."); return } - guard let recognizer = self.recognizer, recognizer.isAvailable, - recognizer.supportsOnDeviceRecognition else { - self.fail("On-device English speech is unavailable."); return - } - do { - let session = AVAudioSession.sharedInstance() - try session.setCategory(.playAndRecord, mode: .measurement, options: [.defaultToSpeaker, .allowBluetooth]) - try session.setActive(true) - let request = SFSpeechAudioBufferRecognitionRequest() - request.shouldReportPartialResults = true - request.taskHint = .dictation - request.requiresOnDeviceRecognition = true - request.contextualStrings = ["Walkieware", "Aesthetic Computer", "garden", "flowers"] - self.request = request - if !AudioBenchmark.enabled { - let input = self.engine.inputNode - let format = input.outputFormat(forBus: 0) - guard format.sampleRate > 0, format.channelCount > 0 else { - self.fail("No microphone is available."); return - } - let sound = self.musicalInput - input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in request.append(buffer); sound.feed(buffer) } - self.tapped = true - self.engine.prepare() - try self.engine.start() - } - self.emit("listening") - self.task = recognizer.recognitionTask(with: request) { [weak self] result, error in - let text = result?.bestTranscription.formattedString - let segments = result?.bestTranscription.segments.map { ["text": $0.substring, "atMs": $0.timestamp * 1000, "durationMs": $0.duration * 1000, "confidence": Double($0.confidence)] as [String: Any] } - let isFinal = result?.isFinal ?? false - let message = error?.localizedDescription - Task { @MainActor in - guard let self, self.turn == id else { return } - if let segments { self.wordSegments = Array(segments.prefix(256)) } - if let text { self.latest = text; self.emit("partial", text: text) } - if isFinal { self.recognitionFinal = true; AudioBenchmark.mark("recognitionFinal") } - if self.audioEnded && isFinal { self.deliver() } - else if self.audioEnded && message != nil && self.latest.isEmpty { - self.deliver() // Sound remains useful when speech finds no words. - } - } - } - if AudioBenchmark.enabled { - let sound = self.musicalInput - self.replayTask = Task { [weak self] in - do { - try await AudioBenchmark.replay(into: request, sound: sound) { - self?.webView?.evaluateJavaScript("voiceEnd()", completionHandler: nil) - } - } catch { if !Task.isCancelled { self?.fail("Audio fixture replay failed.") } } - } - } - self.deadline = Task { [weak self] in - try? await Task.sleep(for: .seconds(8)) - guard !Task.isCancelled, let self, self.turn == id else { return } - self.stop() - } - } catch { self.fail("Could not start the microphone. Please try again.") } - } - } - - private func stopAudio() { - engine.stop() - if tapped { engine.inputNode.removeTap(onBus: 0); tapped = false } - request?.endAudio() - try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) - } - - private func stop() { - guard !turn.isEmpty, held else { return } - held = false - AudioBenchmark.mark("releaseReceived") - guard request != nil else { cancel(); return } - // Capture a release tail, within the eight-second recording budget. A - // thumb lifts a beat before the last word lands; 0.25 s clipped endings. - let elapsed = captureStarted.map { Date().timeIntervalSince($0) } ?? 8 - let tail = min(0.45, max(0, 8 - elapsed)) - emit("processing") - finishing?.cancel() - let id = turn - finishing = Task { [weak self] in - try? await Task.sleep(for: .seconds(tail)) - guard !Task.isCancelled, let self, self.turn == id else { return } - self.stopAudio() - self.audioEnded = true - AudioBenchmark.mark("audioDrained") - if self.recognitionFinal { self.deliver(); return } - // endAudio lets recognition consume every queued buffer and finalize. - // If it never does, the partial transcript is still the person's - // words: send what was heard rather than throw the utterance away. - try? await Task.sleep(for: .seconds(3)) - guard !Task.isCancelled, self.turn == id, !self.delivering else { return } - AudioBenchmark.mark("recognitionTimedOut", fields: ["recognizedCharacters": self.latest.count]) - self.deliver() - } - } - - private func deliver() { - guard !delivering, !turn.isEmpty else { return } - delivering = true - let text = latest.trimmingCharacters(in: .whitespacesAndNewlines) - let id = turn, words = wordSegments - stopAudio() - musicalInput.finish { [weak self] sound in - Task { @MainActor in - guard let self, self.turn == id else { return } - guard !text.isEmpty || (sound["audibleMs"] as? Double ?? 0) >= 150 else { - self.fail("I didn’t hear words or a sound. Hold to try again."); return - } - let value: [String: Any] = ["transcript":text,"words":words,"sound":sound] - guard let data = try? JSONSerialization.data(withJSONObject:value), let json = String(data:data,encoding:.utf8) else { self.fail("Could not read sound input."); return } - AudioBenchmark.checkTranscript(text) - AudioBenchmark.mark("soundSubmitted", fields: value) - self.cancel() - self.emit("mixedFinal", text:json, id:id) - } - } - } - - private func fail(_ message: String) { - AudioBenchmark.mark("recognitionFailed", fields: ["message": message, "recognizedCharacters": latest.count]) - let id = turn - cancel() - emit("error", text: message, id: id) - } - func cancel() { capturePhase = .idle; captureStarted = nil - turn = ""; held = false - replayTask?.cancel(); replayTask = nil - finishing?.cancel(); finishing = nil - deadline?.cancel(); deadline = nil - stopAudio() - task?.cancel(); task = nil; request = nil + capture.cancel() } } diff --git a/apple/walkieware/Sources/WalkiewareScreen.swift b/apple/walkieware/Sources/WalkiewareScreen.swift index 091f48716c..7716ed7418 100644 --- a/apple/walkieware/Sources/WalkiewareScreen.swift +++ b/apple/walkieware/Sources/WalkiewareScreen.swift @@ -263,7 +263,7 @@ struct VersionFeed: View { HStack(spacing: 12) { if snapshot.busy { ProgressView().frame(width: 48) } if snapshot.busy, let output = snapshot.output, !output.isEmpty { - CodeTicker(output: output) + CodeTicker(output: output, thinking: snapshot.phase.hasPrefix("Thinking")) } else { Text(attempt.request.replacingOccurrences(of: #" · [0-9.]+ seconds$"#, with: "", options: .regularExpression)) .font(.custom("ComicRelief-Regular", size: textSize, relativeTo: .title3)) diff --git a/apple/walkieware/Tests/checks.json b/apple/walkieware/Tests/checks.json index 52322d876b..07a2c65f65 100644 --- a/apple/walkieware/Tests/checks.json +++ b/apple/walkieware/Tests/checks.json @@ -778,7 +778,8 @@ "13 relay policy tests passed incl. reasoning shapes", "AcServer tests passed" ], - "limits": "Needs a lith deploy before the phone benefits; the deployed relay drops the field. Live re-measure owed after deploy." + "limits": "Needs a lith deploy before the phone benefits; the deployed relay drops the field. Live re-measure owed after deploy.", + "result": "Deployed relay forwards the field (probe: malformed values get 400). OpenRouter /api/v1/messages ignores every shape for deepseek/deepseek-v4.1-flash: effort none \u2192 4958 thinking tokens, enabled false \u2192 3495, max_tokens 64 \u2192 2364. Thinking varies 2.3K\u20138.1K tokens on the same prompt. Open: does Anthropic-style thinking:{type:\"disabled\"} or /chat/completions honor it (probe-openrouter.mjs, @jeffrey runs it with the vault key)." }, "selectorAndTicker": { "build": 74, @@ -845,5 +846,72 @@ "Device build passed" ], "limits": "Not yet auditioned by voice on the phone this turn." + }, + "modelComparison": { + "date": "2026-10-01", + "edit": "wwGihap v2 basket + \"Someone is eating them\", desktop replay through the production relay, reasoning effort none requested", + "rows": [ + { + "model": "deepseek/deepseek-v4.1-flash", + "totalMs": 15959, + "firstCodeMs": 12687, + "thinkingTokens": 4958, + "quality": "animated: berries vanish, critter chomps (best)" + }, + { + "model": "openai/gpt-5.6-luna", + "totalMs": 12704, + "firstCodeMs": 6037, + "thinkingTokens": 266, + "quality": "static thief head, no eating animation" + }, + { + "model": "qwen/qwen3.7-plus", + "totalMs": 41448, + "firstCodeMs": 7184, + "thinkingTokens": 263, + "quality": "invented tap-to-start interaction; 57 tok/s" + }, + { + "model": "deepseek/deepseek-v4-pro", + "totalMs": 49035, + "firstCodeMs": 24181, + "thinkingTokens": 1444, + "quality": "four rounds" + }, + { + "model": "deepseek/deepseek-chat-v3.1", + "totalMs": 58095, + "firstCodeMs": 1444, + "thinkingTokens": 0, + "quality": "static eater; five rounds, three of them chatter" + } + ], + "conclusion": "Keep DeepSeek v4.1 flash; its hidden thinking is the latency. Until it can be switched off, stream the thinking into the ticker (reasoningStream)." + }, + "reasoningStream": { + "build": 78, + "change": "AcServer emits item/reasoning/delta for thinking_delta events (never kept in the conversation); the engine shows the reasoning in the pending-row ticker, dimmed and plain, until code arrives; phase reads Thinking\u2026", + "validation": [ + "22 AcServer tests passed incl. thinking-delta case", + "native-bridge browser test passed", + "Live replay: first reasoning text at 669 ms, first code at 23.6 s on a heavy run \u2014 the ticker is busy for the whole gap" + ] + }, + "sessionSplit": { + "build": 78, + "change": "SpeechCapture.swift owns the microphone, recognizer, pitch/energy analysis and release timing and reports through onEvent/onLevel/onReplayRelease; WalkiewareSession keeps screen state and the web bridge. WalkiewareApp.swift shrinks by ~180 lines.", + "validation": [ + "Simulator build passed", + "Full UI suite: see sessionSplitUITests" + ] + }, + "sessionSplitUITests": { + "build": 78, + "validation": [ + "SwipeToTypeTests 3/3 passed on the split (inline typing both appearances, silent version scrolling, story restore)", + "HeaderSheetsTests 2/2 passed after the dismissal check became a proper disappearance wait", + "Device build 78 installed (launch blocked: phone locked)" + ] } } \ No newline at end of file diff --git a/apple/walkieware/Tests/fixtures/basket-v2.mjs b/apple/walkieware/Tests/fixtures/basket-v2.mjs new file mode 100644 index 0000000000..ba697ac56b --- /dev/null +++ b/apple/walkieware/Tests/fixtures/basket-v2.mjs @@ -0,0 +1,71 @@ +// Walkieware v2 — a basket of blueberries. +export function paint({ wipe, ink, circle, line, shape, screen }) { + wipe("#151838"); + + const cx = screen.width / 2; + const rimY = screen.height * 0.56; + const botY = screen.height * 0.9; + const topW = Math.min(screen.width * 0.66, 460); + const botW = topW * 0.68; + const r = topW * 0.075; + + // basket body + ink("#7a4a22"); + shape([ + [cx - topW / 2, rimY], [cx + topW / 2, rimY], + [cx + botW / 2, botY], [cx - botW / 2, botY], + ], true); + + const half = (t) => (topW + (botW - topW) * t) / 2; + ink("#96602c"); + for (let i = 1; i < 6; i += 1) { + const t = i / 6; + const y = rimY + (botY - rimY) * t; + line(cx - half(t), y, cx + half(t), y); + } + ink("#673d1a"); + for (let k = -3; k <= 3; k += 1) { + line(cx + (k * topW) / 7, rimY, cx + (k * botW) / 7, botY); + } + + // the pile — back rows first so nearer berries overlap + const rows = [ + { n: 5, y: rimY - r * 0.1 }, + { n: 4, y: rimY - r * 1.05 }, + { n: 2, y: rimY - r * 1.85 }, + ]; + rows.forEach(({ n, y }, row) => { + for (let i = 0; i < n; i += 1) { + const x = cx + (i - (n - 1) / 2) * r * 1.72; + berry(ink, circle, line, x + Math.sin(row * 3 + i * 2) * r * 0.12, y, r, row * 2 + i); + } + }); + + // front rim lip holds them in + ink("#a56a30"); + line(cx - topW / 2, rimY, cx + topW / 2, rimY, r * 0.45); + ink("#c98a45"); + line(cx - topW / 2, rimY - r * 0.22, cx + topW / 2, rimY - r * 0.22, r * 0.12); +} + +function berry(ink, circle, line, x, y, r, seed) { + const shades = ["#2b3390", "#3843ad", "#4653c6", "#5866dc"]; + ink(shades[seed % shades.length]); + circle(x, y, r, true); + + ink("#8a95f6"); + circle(x - r * 0.3, y - r * 0.32, r * 0.34, true); + ink("#cdd3ff"); + circle(x - r * 0.36, y - r * 0.38, r * 0.12, true); + + // calyx: five star points around a small central stem + ink("#191c4d"); + const top = y - r * 0.82; + for (let i = 0; i < 5; i += 1) { + const a = -Math.PI / 2 + (i - 2) * 0.5; + line(x, top, x + Math.cos(a) * r * 0.34, top + Math.sin(a) * r * 0.3); + } + ink("#232666"); + circle(x, top, r * 0.11, true); + line(x, top, x - r * 0.05, top - r * 0.15); +} diff --git a/apple/walkieware/Tests/probe-openrouter.mjs b/apple/walkieware/Tests/probe-openrouter.mjs new file mode 100644 index 0000000000..8f8948fff2 --- /dev/null +++ b/apple/walkieware/Tests/probe-openrouter.mjs @@ -0,0 +1,36 @@ +// Which parameter shape, if any, turns DeepSeek v4.1 flash's thinking off on +// OpenRouter? Reads OPENROUTER_API_KEY from vault/lith/.env in-process (never +// printed), sends one small request per shape, prints status + usage only. +// Run: node probe-openrouter.mjs [model] +import {readFileSync} from 'node:fs'; +const env=readFileSync(new URL('../../../vault/lith/.env',import.meta.url),'utf8'); +const key=env.match(/^OPENROUTER_API_KEY=(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g,''); +if(!key)throw Error('no OPENROUTER_API_KEY in vault/lith/.env'); +const model=process.argv[2]||'deepseek/deepseek-v4.1-flash'; +const prompt='Write a JavaScript function that draws a filled circle at the center of a screen object with width and height, using ink() and circle(). Code only.'; +const headers={Authorization:`Bearer ${key}`,'Content-Type':'application/json','anthropic-version':'2023-06-01','HTTP-Referer':'https://aesthetic.computer','X-Title':'Walkieware probe'}; +const shapes=[ + ['messages · baseline',{}], + ['messages · reasoning.effort none',{reasoning:{effort:'none'}}], + ['messages · reasoning.enabled false',{reasoning:{enabled:false}}], + ['messages · thinking disabled',{thinking:{type:'disabled'}}], + ['messages · reasoning.max_tokens 0',{reasoning:{max_tokens:0}}], + ['chat · reasoning.effort none',null], + ['chat · reasoning.enabled false',{enabled:false}], +]; +for(const [label,extra] of shapes){ + const t0=performance.now(); + const chat=label.startsWith('chat'); + const url=chat?'https://openrouter.ai/api/v1/chat/completions':'https://openrouter.ai/api/v1/messages'; + const body=chat?{model,max_tokens:600,messages:[{role:'user',content:prompt}],reasoning:extra||{effort:'none'}}:{model,max_tokens:600,messages:[{role:'user',content:prompt}],...extra}; + try{ + const r=await fetch(url,{method:'POST',headers,body:JSON.stringify(body)}); + const text=await r.text();let j;try{j=JSON.parse(text);}catch{} + const usage=j?.usage||{}; + const think=usage.output_tokens_details?.thinking_tokens??usage.completion_tokens_details?.reasoning_tokens??'?'; + const out=usage.output_tokens??usage.completion_tokens??'?'; + const stop=j?.stop_reason||j?.choices?.[0]?.finish_reason||''; + const err=j?.error?.message||(r.ok?'':text.slice(0,120)); + console.log(label.padEnd(36),'→',r.status,String(Math.round(performance.now()-t0)).padStart(6)+'ms','thinking',String(think).padStart(5),'out',String(out).padStart(5),stop,err); + }catch(e){console.log(label.padEnd(36),'→ fetch failed',e.message);} +} diff --git a/apple/walkieware/Tests/replay-edit.mjs b/apple/walkieware/Tests/replay-edit.mjs new file mode 100644 index 0000000000..b2a498fe40 --- /dev/null +++ b/apple/walkieware/Tests/replay-edit.mjs @@ -0,0 +1,53 @@ +// Replay one real Walkieware edit from the desktop through the same relay the +// phone uses, and time every round: request → headers → first delta → end, +// with the provider's usage block, stop reason and tool names per round. +// Usage: node Tests/replay-edit.mjs [model] [prompt] +// REPLAY_SOURCE=./fixtures/other.mjs REASONING_JSON='{"effort":"none"}' REASONING=on (provider default) +// Reports land in Tests/audio/ (gitignored evidence). +import {ACSession,USER_AGENT} from '../../../aesel/src/ac-session.mjs'; +import {AcServer} from '../../../aesel/src/ac-server.mjs'; +import {GENERATION_INSTRUCTIONS,DEFAULT_MODEL} from '../Resources/Web/generation-policy.mjs'; +import {inferenceRequest} from '../Resources/Web/inference-input.mjs'; +import {mkdtemp,writeFile,readFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; + +const model=process.argv[2]||DEFAULT_MODEL; +const prompt=process.argv[3]||'Someone is eating them'; +const initial=await readFile(new URL(process.env.REPLAY_SOURCE||'./fixtures/basket-v2.mjs',import.meta.url),'utf8'); +const session=new ACSession();const token=await session.token();if(!token)throw Error('Sign in first'); +const cwd=await mkdtemp(join(tmpdir(),'ww-replay-'));const file=join(cwd,'piece.mjs');await writeFile(file,initial); + +const t0=performance.now();const now=()=>Math.round(performance.now()-t0); +const rounds=[];let round=null;let painted=initial;let feedback=null; +const log=(...a)=>console.log(String(now()).padStart(6)+'ms',...a); +const server=new AcServer({cwd,piece:{file,checkpoint:async()=>{painted=await readFile(file,'utf8');feedback={rendered:true,logs:[],updatedAt:new Date().toISOString()};}}, + model,token:()=>token,preview:true,rounds:12,outputContinuations:4,jev:null,reasoning:process.env.REASONING_JSON?JSON.parse(process.env.REASONING_JSON):process.env.REASONING==='on'?null:{effort:'none'},frameCapture:false,layeredEdits:true,developerInstructions:GENERATION_INSTRUCTIONS, + fetch:async(url,options)=>{ + round={n:rounds.length+1,request:now(),bodyBytes:Buffer.byteLength(options.body),tools:[],deltaBytes:0};rounds.push(round); + const body=JSON.parse(options.body);round.messages=body.messages.length;round.systemBytes=JSON.stringify(body.system).length;round.toolDefs=body.tools?.length; + const response=await fetch(url,{...options,headers:{...options.headers,'User-Agent':USER_AGENT}}); + round.headers=now();log(`round ${round.n} headers after ${round.headers-round.request}ms · body ${round.bodyBytes}B · ${round.messages} msgs`); + return response; + }}); +server.runtimeFeedback=()=>feedback; +server.on('notification',({method,params})=>{ + if(!round)return; + if(method==='item/modelCode/delta'){round.firstDelta??=now();round.deltaBytes+=params.delta.length;if(!round.tools.includes(params.tool||'write_piece'))round.tools.push(params.tool||'write_piece');} + if(method==='item/agentMessage/delta'){round.firstDelta??=now();round.text=(round.text||'')+params.delta;} + if(method==='item/reasoning/delta'){round.firstReasoning??=now();round.reasoningChars=(round.reasoningChars||0)+params.delta.length;} + if(method==='item/started')round.tools.push(params.item?.tool||params.item?.type); + if(method==='item/completed'){round.completed=(round.completed||[]);round.completed.push(params.item?.status||params.item?.path);log(` tool done: ${params.item?.status||params.item?.summary||params.item?.path}`);} + if(method==='turn/usage'){round.usage=params.usage;round.end=now();log(` round ${round.n} end · ${round.end-round.request}ms · usage ${JSON.stringify(params.usage)}`);} + if(method==='turn/progress'&¶ms.phase==='continuing')log(' CONTINUATION',params.continuation); + if(method==='turn/progress'&&['generating','composing'].includes(params.phase)){round.firstByte??=now();round.phases=(round.phases||'')+(round.phases?.endsWith(params.phase[0])?'':params.phase[0]);} + if(method==='turn/completed'){log('turn completed',params.turn.status,params.turn.error?.message||'');} + if(method==='model/reported')round.reported=params.reported; +}); +const deadline=setTimeout(()=>{log('DEADLINE interrupt');server.interrupt();},240000); +try{await server.startTurn(inferenceRequest(prompt));}catch(e){log('error',e.message);}finally{clearTimeout(deadline);server.close();} +const final=await readFile(file,'utf8'); +console.log('\n=== SUMMARY',model,JSON.stringify(prompt),'reasoning',process.env.REASONING_JSON||(process.env.REASONING==='on'?'default':'none')); +console.log('total',now(),'ms · rounds',rounds.length,'· source',initial.length,'→',final.length,'chars · changed',final!==initial); +for(const r of rounds)console.log(JSON.stringify({n:r.n,ttHeaders:r.headers-r.request,ttFirstDelta:r.firstDelta?r.firstDelta-r.request:null,ttFirstByte:r.firstByte?r.firstByte-r.request:null,ttFirstReasoning:r.firstReasoning?r.firstReasoning-r.request:null,reasoningChars:r.reasoningChars||0,phases:r.phases,duration:(r.end||now())-r.request,tools:r.tools,deltaBytes:r.deltaBytes,usage:r.usage,text:(r.text||'').slice(0,120),bodyBytes:r.bodyBytes,msgs:r.messages})); +await writeFile(new URL(`./audio/replay-${model.replace(/\W/g,'_')}-${Date.now()}.json`,import.meta.url),JSON.stringify({model,prompt,rounds,total:now(),final},null,1)); diff --git a/apple/walkieware/UITests/HeaderSheetsTests.swift b/apple/walkieware/UITests/HeaderSheetsTests.swift index a38c0a2d61..5cfb0d9ad6 100644 --- a/apple/walkieware/UITests/HeaderSheetsTests.swift +++ b/apple/walkieware/UITests/HeaderSheetsTests.swift @@ -4,6 +4,12 @@ import XCTest // the pieces sheet with a way to start a new piece. Runs on the simulator // against the "history" fixture, which signs nothing in and reaches no cloud. final class HeaderSheetsTests: XCTestCase { + /// Sheets dismiss with an animation; poll until the element is really gone. + private func waitForDisappearance(of element: XCUIElement, timeout: TimeInterval = 6) -> Bool { + let gone = expectation(for: NSPredicate(format: "exists == false"), evaluatedWith: element) + return XCTWaiter().wait(for: [gone], timeout: timeout) == .completed + } + private func launch() -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["WALKIE_NATIVE_SCREEN_FIXTURE"] = "history" @@ -19,7 +25,7 @@ final class HeaderSheetsTests: XCTestCase { XCTAssertTrue(app.buttons["pieces-new"].waitForExistence(timeout: 10), "the pieces sheet offers New piece") XCTAssertTrue(app.buttons["piece-wwDemo"].exists, "the open piece is listed by its code") app.buttons["Done"].tap() - XCTAssertFalse(app.buttons["pieces-new"].waitForExistence(timeout: 2)) + XCTAssertTrue(waitForDisappearance(of: app.buttons["pieces-new"]), "Done closes the pieces sheet") } func testHandleOpensAccountSheetWithSignOut() { @@ -30,6 +36,6 @@ final class HeaderSheetsTests: XCTestCase { XCTAssertTrue(app.buttons["account-sign-out"].waitForExistence(timeout: 10), "a signed-in fixture handle shows Sign out") XCTAssertTrue(app.segmentedControls.firstMatch.exists, "appearance moved into the account sheet") app.buttons["Done"].tap() - XCTAssertFalse(app.buttons["account-sign-out"].waitForExistence(timeout: 2)) + XCTAssertTrue(waitForDisappearance(of: app.buttons["account-sign-out"]), "Done closes the account sheet") } } diff --git a/apple/walkieware/Walkieware.xcodeproj/project.pbxproj b/apple/walkieware/Walkieware.xcodeproj/project.pbxproj index 04d1bd97a4..fdea05e490 100644 --- a/apple/walkieware/Walkieware.xcodeproj/project.pbxproj +++ b/apple/walkieware/Walkieware.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 91EBE2405BCB456959613E70 /* RecordingTrim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35720E8232DBFB5B795FBEEA /* RecordingTrim.swift */; }; 97AB97086118FC695F4B2133 /* AudioBenchmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45F6D30D7883EC26BF391CC7 /* AudioBenchmark.swift */; }; 99B11F258C6DD8DEAD32A419 /* MusicalInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A7C5768F0EE9F4A8A288767 /* MusicalInput.swift */; }; + B01CCEF94DAC4203687C68CB /* SpeechCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 054D4872CAC359CCAFE93F95 /* SpeechCapture.swift */; }; C57CC34DE256A25EDF176C4A /* NativeScreenFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF403AD7661862BCAC9B2F95 /* NativeScreenFixture.swift */; }; E0B6A4DB98A5C44344C74589 /* WorkspaceCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D40BA9A3C78CC0324903812B /* WorkspaceCoordinator.swift */; }; F1E262539EDCC86364A1B426 /* UtteranceRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A3D6D0D1C0C32051B6C6AF4 /* UtteranceRecording.swift */; }; @@ -45,6 +46,7 @@ /* Begin PBXFileReference section */ 03D832B5A1A03E760A43BBF8 /* WalkiewareHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalkiewareHeader.swift; sourceTree = ""; }; + 054D4872CAC359CCAFE93F95 /* SpeechCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechCapture.swift; sourceTree = ""; }; 06F16D0B24CA61442FFD0E27 /* SequenceBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SequenceBenchmark.swift; sourceTree = ""; }; 110077D3E4AF92775C4E6D74 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 1A7C5768F0EE9F4A8A288767 /* MusicalInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicalInput.swift; sourceTree = ""; }; @@ -95,6 +97,7 @@ 50D5B994181BB8DD173DBA13 /* PlaybackWord.swift */, 35720E8232DBFB5B795FBEEA /* RecordingTrim.swift */, 06F16D0B24CA61442FFD0E27 /* SequenceBenchmark.swift */, + 054D4872CAC359CCAFE93F95 /* SpeechCapture.swift */, 4A3D6D0D1C0C32051B6C6AF4 /* UtteranceRecording.swift */, E8E12122840B2375C0AB2ED0 /* VersionNarrator.swift */, F3EC37299741D5E90E45EE5F /* WalkiewareAccount.swift */, @@ -263,6 +266,7 @@ 09A89EDEB03772B566D345A9 /* PlaybackWord.swift in Sources */, 91EBE2405BCB456959613E70 /* RecordingTrim.swift in Sources */, 7EC06C94440CDAF9B846F0BB /* SequenceBenchmark.swift in Sources */, + B01CCEF94DAC4203687C68CB /* SpeechCapture.swift in Sources */, F1E262539EDCC86364A1B426 /* UtteranceRecording.swift in Sources */, 27410D22F96D8006B6D29452 /* VersionNarrator.swift in Sources */, 28DE33D8B94B853B408571A1 /* WalkiewareAccount.swift in Sources */, @@ -390,7 +394,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -457,7 +461,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_NS_ASSERTIONS = NO; diff --git a/apple/walkieware/project.yml b/apple/walkieware/project.yml index 3439ec3f7e..23a06b4932 100644 --- a/apple/walkieware/project.yml +++ b/apple/walkieware/project.yml @@ -9,7 +9,7 @@ settings: CODE_SIGN_STYLE: Automatic SWIFT_VERSION: "5.0" MARKETING_VERSION: "0.1.0" - CURRENT_PROJECT_VERSION: "77" + CURRENT_PROJECT_VERSION: "78" targets: Walkieware: type: application