Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds support for custom voices in ChatView #16

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
48 changes: 41 additions & 7 deletions Sources/SpeziChat/ChatView+SpeechButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,23 @@
// SPDX-License-Identifier: MIT
//

import AVFoundation
import SpeziSpeechSynthesizer
import SwiftUI


/// The underlying `ViewModifier` of `View/speechToolbarButton(enabled:muted:)`.
private struct ChatViewSpeechButtonModifier: ViewModifier {
@Binding var muted: Bool

@Binding var selectedVoice: String
@State private var isVoiceSelectionSheetPresented = false
@State var speechSynthesizer = SpeechSynthesizer()

func body(content: Content) -> some View {
content
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: {
muted.toggle()
}) {
Button(action: {}) {
if !muted {
Image(systemName: "speaker")
.accessibilityIdentifier("Speaker")
Expand All @@ -31,6 +33,34 @@ private struct ChatViewSpeechButtonModifier: ViewModifier {
.accessibilityLabel(Text("Text to speech is disabled, press to enable text to speech.", bundle: .module))
}
}
.simultaneousGesture(LongPressGesture().onEnded { _ in
isVoiceSelectionSheetPresented.toggle()
muted = false
})
.simultaneousGesture(TapGesture().onEnded { _ in
muted.toggle()
})
}
}
.sheet(isPresented: $isVoiceSelectionSheetPresented) {
NavigationView {
Form {
Picker("Select Voice", selection: $selectedVoice) {
ForEach(speechSynthesizer.voices, id: \.self) { voice in
Text(voice.name)
.tag(voice.identifier)
}
}
.pickerStyle(.inline)
}
.navigationTitle("Voice")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Done") {
isVoiceSelectionSheetPresented = false
}
}
}
}
}
}
Expand Down Expand Up @@ -72,12 +102,15 @@ extension View {
///
/// - Parameters:
/// - muted: A SwiftUI `Binding` that indicates if the speech output is currently muted. The `Binding` enables the adjustment of the muted status by both the caller and the toolbar `Button`.
/// - selectedVoice: A SwiftUI `Binding` that contains the identifier of an `AVSpeechSynthesisVoice` to use.
public func speechToolbarButton(
muted: Binding<Bool>
muted: Binding<Bool>,
selectedVoice: Binding<String>
) -> some View {
modifier(
ChatViewSpeechButtonModifier(
muted: muted
muted: muted,
selectedVoice: selectedVoice
)
)
}
Expand All @@ -94,12 +127,13 @@ extension View {
]
)
@State var muted = true
@State var selectedVoice = "com.apple.speech.synthesis.voice.Fred"


return NavigationStack {
ChatView($chat)
.speak(chat, muted: muted)
.speechToolbarButton(muted: $muted)
.speechToolbarButton(muted: $muted, selectedVoice: $selectedVoice)
}
}
#endif
17 changes: 13 additions & 4 deletions Sources/SpeziChat/ChatView+SpeechOutput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// SPDX-License-Identifier: MIT
//

import AVFoundation
import SpeziSpeechSynthesizer
import SwiftUI

Expand All @@ -14,14 +15,16 @@ import SwiftUI
private struct ChatViewSpeechModifier: ViewModifier {
let chat: Chat
let muted: Bool
let voice: AVSpeechSynthesisVoice?

@Environment(\.scenePhase) private var scenePhase
@State private var speechSynthesizer = SpeechSynthesizer()


init(chat: Chat, muted: Bool) {
init(chat: Chat, muted: Bool, voice: AVSpeechSynthesisVoice? = nil) {
self.chat = chat
self.muted = muted
self.voice = voice
}


Expand All @@ -37,7 +40,11 @@ private struct ChatViewSpeechModifier: ViewModifier {
}

if lastChatEntity.role == .assistant {
speechSynthesizer.speak(lastChatEntity.content)
if let voice {
speechSynthesizer.speak(lastChatEntity.content, voice: voice)
} else {
speechSynthesizer.speak(lastChatEntity.content)
}
} else if lastChatEntity.role == .user {
speechSynthesizer.stop()
}
Expand Down Expand Up @@ -101,12 +108,14 @@ extension View {
/// - muted: Indicates if the speech output is currently muted, defaults to `false`.
public func speak(
_ chat: Chat,
muted: Bool = false
muted: Bool = false,
voice: String = ""
) -> some View {
modifier(
ChatViewSpeechModifier(
chat: chat,
muted: muted
muted: muted,
voice: AVSpeechSynthesisVoice(identifier: voice)
)
)
}
Expand Down
9 changes: 9 additions & 0 deletions Sources/SpeziChat/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
}
}
}
},
"Done" : {

},
"EXPORT_CHAT_BUTTON" : {
"localizations" : {
Expand Down Expand Up @@ -74,6 +77,9 @@
}
}
}
},
"Select Voice" : {

},
"SEND_MESSAGE" : {
"localizations" : {
Expand Down Expand Up @@ -112,6 +118,9 @@
}
}
}
},
"Voice" : {

}
},
"version" : "1.0"
Expand Down
5 changes: 3 additions & 2 deletions Tests/UITests/TestApp/ChatTestView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct ChatTestView: View {
ChatEntity(role: .assistant, content: "**Assistant** Message!")
]
@State private var muted = true
@State var selectedVoice = ""


var body: some View {
Expand All @@ -24,8 +25,8 @@ struct ChatTestView: View {
exportFormat: .pdf,
messagePendingAnimation: .automatic
)
.speak(chat, muted: muted)
.speechToolbarButton(muted: $muted)
.speak(chat, muted: muted, voice: selectedVoice)
.speechToolbarButton(muted: $muted, selectedVoice: $selectedVoice)
.navigationTitle("SpeziChat")
.padding(.top, 16)
.onChange(of: chat) { _, newValue in
Expand Down
17 changes: 17 additions & 0 deletions Tests/UITests/TestAppUITests/TestAppUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
// Store exported chat in Files
#if os(visionOS)
// On visionOS the "Save to files" button has no label
XCTAssert(app.cells["XCElementSnapshotPrivilegedValuePlaceholder"].waitForExistence(timeout: 10))

Check failure on line 73 in Tests/UITests/TestAppUITests/TestAppUITests.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests visionOS (Debug, TestApp-visionOS.xcresult, TestApp-visionOS.xcresult) / Test using xcodebuild or run fastlane

testChatExport, XCTAssertTrue failed

Check failure on line 73 in Tests/UITests/TestAppUITests/TestAppUITests.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests visionOS (Release, TestApp-visionOS-Release.xcresult, TestApp-visionOS-Re... / Test using xcodebuild or run fastlane

testChatExport, XCTAssertTrue failed
app.cells["XCElementSnapshotPrivilegedValuePlaceholder"].tap()
#else
XCTAssert(app.staticTexts["Save to Files"].waitForExistence(timeout: 10))
Expand All @@ -78,7 +78,7 @@
#endif

sleep(3)
XCTAssert(app.buttons["Save"].waitForExistence(timeout: 2))

Check failure on line 81 in Tests/UITests/TestAppUITests/TestAppUITests.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests iOS (Debug, TestApp-iOS.xcresult, TestApp-iOS.xcresult) / Test using xcodebuild or run fastlane

testChatExport, XCTAssertTrue failed
app.buttons["Save"].tap()
sleep(10) // Wait until file is saved

Expand Down Expand Up @@ -150,4 +150,21 @@
XCTAssert(!app.buttons["Speaker strikethrough"].waitForExistence(timeout: 2))
XCTAssert(app.buttons["Speaker"].waitForExistence(timeout: 2))
}

func testSelectVoice() throws {
let app = XCUIApplication()

XCTAssert(app.staticTexts["SpeziChat"].waitForExistence(timeout: 1))
XCTAssert(app.buttons["Speaker strikethrough"].waitForExistence(timeout: 2))
XCTAssert(!app.buttons["Speaker"].waitForExistence(timeout: 2))

app.buttons["Speaker strikethrough"].press(forDuration: 3)

XCTAssert(app.staticTexts["Voice"].waitForExistence(timeout: 2))

app.buttons["Albert"].tap()
app.swipeDown()

Check failure on line 166 in Tests/UITests/TestAppUITests/TestAppUITests.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests iOS (Release, TestApp-iOS-Release.xcresult, TestApp-iOS-Release.xcresult) / Test using xcodebuild or run fastlane

testSelectVoice, Failed to application edu.stanford.spezichat.testapp is not running

Check failure on line 166 in Tests/UITests/TestAppUITests/TestAppUITests.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests visionOS (Debug, TestApp-visionOS.xcresult, TestApp-visionOS.xcresult) / Test using xcodebuild or run fastlane

testSelectVoice, Failed to received invalid scene ID (nil) from Accessibility.

Check failure on line 166 in Tests/UITests/TestAppUITests/TestAppUITests.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests visionOS (Release, TestApp-visionOS-Release.xcresult, TestApp-visionOS-Re... / Test using xcodebuild or run fastlane

testSelectVoice, Failed to received invalid scene ID (nil) from Accessibility.

XCTAssert(app.buttons["Speaker"].waitForExistence(timeout: 2))
}
}
Loading