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
1 change: 1 addition & 0 deletions apps/mobile/.swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ included:
- modules/t3-native-controls/ios
- modules/t3-terminal/ios
- modules/t3-review-diff/ios
- modules/t3-voice-transcription/ios

excluded:
- ios/Pods
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ class T3VoiceTranscriptionModule : Module() {
override fun definition() = ModuleDefinition {
Name("T3VoiceTranscription")

defineDecodeRecording()

AsyncFunction("checkAvailability").Coroutine<Boolean> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
false
Expand Down Expand Up @@ -104,6 +106,21 @@ class T3VoiceTranscriptionModule : Module() {
}
}

private fun ModuleDefinitionBuilder.defineDecodeRecording() {
AsyncFunction("decodeRecording").Coroutine { uri: String ->
val context = requireNotNull(appContext.reactContext)
withContext(Dispatchers.IO) {
require(android.net.Uri.parse(uri).scheme == "file") { "Expected a local recording." }
withRecordedPcm(context, uri) { pcm ->
requireCompleteAudio(pcm.length() <= 16000L * 2 * 301) {
"Recording exceeds five minutes."
}
pcm.readBytes()
}
}
}
}

private fun ModuleDefinitionBuilder.defineLiveRecorder() {
Class(LiveVoiceRecorder::class) {
Constructor {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
{
"platforms": ["android"],
"platforms": ["apple", "android"],
"apple": {
"modules": ["T3VoiceTranscriptionModule"],
"podspecPath": "ios/T3VoiceTranscription.podspec"
},
"android": {
"modules": ["expo.modules.t3voicetranscription.T3VoiceTranscriptionModule"]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Pod::Spec.new do |s|
s.name = 'T3VoiceTranscription'
s.version = '1.0.0'
s.summary = 'Local recording conversion for T3 Code dictation.'
s.author = 'T3 Tools'
s.homepage = 'https://t3tools.com'
s.platform = :ios, '18.0'
s.source = { :path => '.' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.source_files = '*.swift'
s.frameworks = 'AudioToolbox'
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import AudioToolbox
import ExpoModulesCore
import Foundation

public class T3VoiceTranscriptionModule: Module {
public func definition() -> ModuleDefinition {
Name("T3VoiceTranscription")

AsyncFunction("decodeRecording") { (uri: URL) throws -> Data in
guard uri.isFileURL else { throw CocoaError(.fileReadUnsupportedScheme) }
var opened: ExtAudioFileRef?
try check(ExtAudioFileOpenURL(uri as CFURL, &opened))
guard let file = opened else { throw CocoaError(.fileReadCorruptFile) }
defer { ExtAudioFileDispose(file) }
var format = AudioStreamBasicDescription(
mSampleRate: 16_000, mFormatID: kAudioFormatLinearPCM,
mFormatFlags: kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked,
mBytesPerPacket: 2, mFramesPerPacket: 1, mBytesPerFrame: 2,
mChannelsPerFrame: 1, mBitsPerChannel: 16, mReserved: 0)
let formatSize = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
try check(ExtAudioFileSetProperty(file, kExtAudioFileProperty_ClientDataFormat, formatSize, &format))
var pcm = Data()
var chunk = [Int16](repeating: 0, count: 8192)
try chunk.withUnsafeMutableBytes { bytes in
while true {
var frames = UInt32(bytes.count / 2)
var buffers = AudioBufferList(mNumberBuffers: 1, mBuffers: AudioBuffer(
mNumberChannels: 1, mDataByteSize: UInt32(bytes.count), mData: bytes.baseAddress))
try check(ExtAudioFileRead(file, &frames, &buffers))
if frames == 0 { break }
let count = Int(frames) * 2
guard pcm.count + count <= 16_000 * 2 * 301 else { throw CocoaError(.fileReadTooLarge) }
pcm.append(contentsOf: bytes.prefix(count))
}
}
guard !pcm.isEmpty else { throw CocoaError(.fileReadCorruptFile) }
return pcm
}
}
}

private func check(_ status: OSStatus) throws {
guard status == noErr else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) }
}
15 changes: 10 additions & 5 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ import {
} from "./features/settings/ScheduledTaskPickerScreens";
import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen";
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
import { SettingsMicrophoneRouteScreen } from "./features/settings/SettingsMicrophoneRouteScreen";
import { SettingsVoiceRouteScreen } from "./features/settings/SettingsVoiceRouteScreen";
import { SettingsVoiceModelsRouteScreen } from "./features/settings/SettingsVoiceModelsRouteScreen";
import {
SettingsOpenSourceLicenseRouteScreen,
SettingsOpenSourceLicensesRouteScreen,
Expand Down Expand Up @@ -265,10 +266,14 @@ const SETTINGS_SCREENS = {
linking: "settings/keyboard",
options: settingsPage("Keyboard"),
}),
SettingsMicrophone: createNativeStackScreen({
screen: SettingsMicrophoneRouteScreen,
linking: "settings/microphone",
options: settingsPage("Microphone"),
SettingsVoice: createNativeStackScreen({
screen: SettingsVoiceRouteScreen,
linking: { path: "settings/voice", alias: ["settings/microphone"] },
options: settingsPage("Voice"),
}),
SettingsVoiceModels: createNativeStackScreen({
screen: SettingsVoiceModelsRouteScreen,
options: SEARCH_SHEET_ROUTE,
}),
SettingsFollowUp: createNativeStackScreen({
screen: SettingsFollowUpRouteScreen,
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/ui/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ import IconTextIncrease from "@tabler/icons-react-native/IconTextIncrease";
import IconTool from "@tabler/icons-react-native/IconTool";
import IconTrash from "@tabler/icons-react-native/IconTrash";
import IconTypography from "@tabler/icons-react-native/IconTypography";
import IconDownload from "@tabler/icons-react-native/IconDownload";
import IconUpload from "@tabler/icons-react-native/IconUpload";
import IconUserCircle from "@tabler/icons-react-native/IconUserCircle";
import IconUserCheck from "@tabler/icons-react-native/IconUserCheck";
Expand Down Expand Up @@ -277,6 +278,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
"textformat.size": IconTypography,
"textformat.size.larger": IconTextIncrease,
"textformat.size.smaller": IconTextDecrease,
"tray.and.arrow.down": IconDownload,
"tray.and.arrow.up": IconUpload,
trash: IconTrash,
wifi: IconWifi,
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/components/ui/ListChoiceRow.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { Platform, View } from "react-native";
import { useResolveClassNames } from "uniwind";

Expand All @@ -9,7 +10,9 @@ import { RowPressable } from "./RowPressable";

export function ListChoiceRow(props: {
readonly label: string;
readonly leading?: ReactNode;
readonly description?: string;
readonly meta?: ReactNode;
readonly selected: boolean;
readonly separated?: boolean;
readonly disabled?: boolean;
Expand All @@ -31,7 +34,9 @@ export function ListChoiceRow(props: {
>
<ListRowLayout
label={props.label}
leading={props.leading}
description={props.description}
meta={props.meta}
disabled={props.disabled}
accessory={
<View
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/components/ui/ListRowLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,23 @@ import { SymbolView, type AppSymbolName } from "./AppSymbol";

export interface ListRowContentProps {
readonly icon?: AppSymbolName;
/** Drawn in the icon column for marks a single symbol can't express. */
readonly leading?: ReactNode;
readonly label: string;
readonly description?: string;
readonly detail?: string;
/** Rich secondary content under the description, such as scores with emphasized values. */
readonly meta?: ReactNode;
readonly disabled?: boolean;
}

export function ListRowLayout({
icon,
leading,
label,
description,
detail,
meta,
disabled,
accessory,
tone = "default",
Expand All @@ -42,6 +48,8 @@ export function ListRowLayout({
weight="regular"
/>
</View>
) : leading ? (
<View className="size-6 items-center justify-center">{leading}</View>
) : null}
<View className="min-w-0 flex-1 gap-1">
<AppText
Expand All @@ -55,6 +63,7 @@ export function ListRowLayout({
{description}
</AppText>
) : null}
{meta}
{detail && stackDetail ? (
<AppText textRole="footnote" className="text-foreground-muted">
{detail}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ describe("opening a workspace tab", () => {
"Settings",
"SettingsEnvironments",
]);
expect(
names(
workspaceTabNavigationState(state, "settings", { settings: "SettingsMicrophone" }, columns),
),
).toEqual(["Home", "Settings", "SettingsVoice"]);
});

it("keeps the route that is already open, so re-selecting a tab doesn't rebuild it", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const REMEMBERED_SETTINGS_PAGES: ReadonlySet<string> = new Set(
);

export function rememberedSettingsPage(selections: WorkspaceSelections): SettingsPageTarget {
if (selections.settings === "SettingsMicrophone") return "SettingsVoice";
return selections.settings && REMEMBERED_SETTINGS_PAGES.has(selections.settings)
? (selections.settings as SettingsPageTarget)
: DEFAULT_SETTINGS_PAGE;
Expand Down
12 changes: 10 additions & 2 deletions apps/mobile/src/features/pull-requests/PullRequestSearchSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useNavigation } from "@react-navigation/native";
import type { ReactNode } from "react";
import { View } from "react-native";
import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { AppBar } from "../../components/ui/AppBar";
import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar";
Expand All @@ -20,6 +21,7 @@ export function PullRequestSearchSheet(props: {
const navigation = useNavigation();
// The compact search toolbar floats over the end of the list.
const bottomSearch = useNativeMailSearchToolbar();
const insets = useSafeAreaInsets();
return (
<View className="flex-1 bg-sheet" collapsable={false}>
<AppBar
Expand All @@ -37,7 +39,13 @@ export function PullRequestSearchSheet(props: {
}}
/>
{props.children({
paddingBottom: bottomSearch ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 16 : 16,
// Android's bottom inset also covers the floating workspace tabs while Settings is open.
paddingBottom:
Platform.OS === "android"
? insets.bottom + 16
: bottomSearch
? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 16
: 16,
})}
</View>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsScreen } from "./components/SettingsScreen";
import { formatBytes } from "../../lib/formatBytes";

export function SettingsClientStorageRouteScreen() {
const insets = useSafeAreaInsets();
Expand Down Expand Up @@ -179,9 +180,3 @@ function CacheEnvironmentRow(props: {
</View>
);
}

function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
}
2 changes: 1 addition & 1 deletion apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ function useSettingsIndexSections(): SettingsListSection[] {
? [page("SettingsNavigation", "line.3.horizontal", "Navigation tabs")]
: []),
...(Platform.OS === "ios" ? [page("SettingsKeyboard", "keyboard", "Keyboard")] : []),
page("SettingsMicrophone", "mic", "Microphone"),
page("SettingsVoice", "mic", "Voice"),
],
},
{
Expand Down
Loading
Loading