astra signal initial commit

This commit is contained in:
Boof2015
2026-07-15 00:55:44 -04:00
parent 36fadf9925
commit ae3a49cf68
16 changed files with 1101 additions and 0 deletions
+7
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@boof2015/astra-signal": "^0.3.0",
"@boof2015/xlrc": "^0.2.1",
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/jetbrains-mono": "^0.4.1",
@@ -1164,6 +1165,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@boof2015/astra-signal": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@boof2015/astra-signal/-/astra-signal-0.3.0.tgz",
"integrity": "sha512-hPzi9Qv7VUK7DE+GWxciAIKm7QSgt5szdy+vwtavP0KvQEt8H8AqHO81xAgEPuR5d5EeaeEDXCF86PNXVJ9n5w==",
"license": "MIT"
},
"node_modules/@boof2015/xlrc": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@boof2015/xlrc/-/xlrc-0.2.1.tgz",
+2
View File
@@ -3,6 +3,7 @@
"main": "index.js",
"version": "0.1.0",
"dependencies": {
"@boof2015/astra-signal": "^0.3.0",
"@boof2015/xlrc": "^0.2.1",
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/jetbrains-mono": "^0.4.1",
@@ -72,6 +73,7 @@
"test:artist-grouping": "node --experimental-strip-types --test src/library/artistGrouping.test.mts",
"test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/services/desktopSyncPolicy.test.mts src/shared/sync/conflictPreview.test.mts",
"test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts",
"test:signal": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/signalShare.test.mts src/audio/signalShareIntent.test.mts src/audio/signalScanGeometry.test.mts",
"test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts",
"test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts",
"test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts",
+4
View File
@@ -3,6 +3,7 @@ import {
resolveNotificationClick,
} from '@/audio/notificationIntent';
import { getEQPresetShareRedirectPath } from '@/audio/eqShareIntent';
import { getSignalShareRedirectPath } from '@/audio/signalShareIntent';
type RedirectSystemPathEvent = {
path: string;
@@ -15,5 +16,8 @@ export async function redirectSystemPath({ path }: RedirectSystemPathEvent): Pro
const eqShareRedirect = getEQPresetShareRedirectPath(path);
if (eqShareRedirect) return eqShareRedirect;
const signalRedirect = getSignalShareRedirectPath(path);
if (signalRedirect) return signalRedirect;
return path;
}
+86
View File
@@ -0,0 +1,86 @@
import { useMemo } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { SignalResultCard } from '@/components/signal/SignalResultCard';
import { decodeTrackSignalLink, SIGNAL_LINK_PREFIX } from '@/audio/signalShare';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { SignalPayload } from '@boof2015/astra-signal';
export default function SignalImportScreen() {
const styles = useStyles();
const ripple = useRipple();
const colors = useColors();
const router = useRouter();
const { data } = useLocalSearchParams<{ data?: string }>();
const payload = useMemo<SignalPayload | null>(() => {
if (!data) return null;
try {
return decodeTrackSignalLink(`${SIGNAL_LINK_PREFIX}${data}`);
} catch {
return null;
}
}, [data]);
const goToSignal = () => router.replace('/signal' as never);
return (
<Screen>
<View style={styles.header}>
<Pressable android_ripple={ripple.bounded} style={styles.back} onPress={goToSignal} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Signal
</Text>
</Pressable>
</View>
{payload ? (
<SignalResultCard payload={payload} />
) : (
<View style={styles.errorCard}>
<Ionicons name="alert-circle-outline" size={28} color={colors.warning} />
<Text variant="body">This link does not contain a valid Astra Signal.</Text>
<Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={goToSignal}>
<Text variant="body" color={colors.accentTextStrong}>
Go to Signal
</Text>
</Pressable>
</View>
)}
</Screen>
);
}
const useStyles = createThemedStyles((colors) => ({
header: {
marginTop: spacing.md,
marginBottom: spacing.lg,
},
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
errorCard: {
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
padding: spacing.lg,
gap: spacing.md,
},
primaryButton: {
minHeight: 44,
borderRadius: radius.sm,
backgroundColor: colors.accent,
paddingHorizontal: spacing.lg,
alignItems: 'center',
justifyContent: 'center',
},
}));
+181
View File
@@ -0,0 +1,181 @@
import { useMemo, useRef } from 'react';
import { Pressable, Share, StyleSheet, useWindowDimensions, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { cacheDirectory, EncodingType, writeAsStringAsync } from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { SignalCode, type SignalCodeHandle } from '@/components/signal/SignalCode';
import { encodeTrackSignalLink, signalLayoutFromTrack } from '@/audio/signalShare';
import { usePlayerStore } from '@/stores/playerStore';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
// Keep the device presentation identical to the canonical shared PNG. The
// near-white isolation field is part of the proven phone-to-phone geometry.
const CODE_FG = '#0b0b12';
const CODE_BG = '#f4f4f6';
export default function SignalScreen() {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const router = useRouter();
const track = usePlayerStore((s) => s.currentTrack);
const codeRef = useRef<SignalCodeHandle>(null);
const { width: screenWidth } = useWindowDimensions();
const layout = useMemo(() => (track ? signalLayoutFromTrack(track) : null), [track]);
const availableWidth = Math.max(1, screenWidth - spacing.lg * 4);
const targetWidth = layout?.tier === 'small' ? 280 : layout?.tier === 'medium' ? 340 : availableWidth;
const codeWidth = Math.min(targetWidth, availableWidth);
const shareImage = async () => {
const base64 = codeRef.current?.snapshot();
if (!base64 || !cacheDirectory) return;
const fileUri = `${cacheDirectory}astra-signal.png`;
await writeAsStringAsync(fileUri, base64, { encoding: EncodingType.Base64 });
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(fileUri, {
mimeType: 'image/png',
dialogTitle: 'Share Astra Signal',
UTI: 'public.png',
});
}
};
const shareLink = async () => {
if (!track) return;
await Share.share({ message: encodeTrackSignalLink(track) });
};
return (
<Screen>
<View style={styles.header}>
<Pressable android_ripple={ripple.bounded} style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Back
</Text>
</Pressable>
<Pressable android_ripple={ripple.icon(22)} style={styles.scanBtn} onPress={() => router.navigate('/signal/scan' as never)} hitSlop={8}>
<Ionicons name="scan-outline" size={22} color={colors.accent} />
</Pressable>
</View>
<Text variant="title" style={styles.heading}>
Astra Signal
</Text>
{!track || !layout ? (
<View style={styles.empty}>
<Ionicons name="pulse-outline" size={30} color={colors.textTertiary} />
<Text variant="body" color={colors.textSecondary}>
Play a song to make its Signal.
</Text>
</View>
) : (
<View style={styles.body}>
<View style={styles.codeCard}>
<SignalCode
ref={codeRef}
layout={layout}
width={codeWidth}
foreground={CODE_FG}
background={CODE_BG}
exportForeground={CODE_FG}
exportBackground={CODE_BG}
/>
</View>
<Text variant="heading" style={styles.trackTitle} numberOfLines={1}>
{track.title}
</Text>
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
{track.artist}
</Text>
<View style={styles.actions}>
<Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={() => void shareImage()}>
<Ionicons name="share-outline" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}>
Share Signal
</Text>
</Pressable>
<Pressable android_ripple={ripple.bounded} style={styles.secondaryButton} onPress={() => void shareLink()}>
<Ionicons name="link-outline" size={18} color={colors.textPrimary} />
<Text variant="body">Share link</Text>
</Pressable>
</View>
</View>
)}
</Screen>
);
}
const useStyles = createThemedStyles((colors) => ({
header: {
marginTop: spacing.md,
marginBottom: spacing.lg,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
scanBtn: {
padding: spacing.xs,
},
heading: {
marginBottom: spacing.lg,
},
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.md,
},
body: {
alignItems: 'center',
},
codeCard: {
padding: spacing.lg,
borderRadius: radius.lg,
backgroundColor: CODE_BG,
marginBottom: spacing.lg,
overflow: 'hidden',
},
trackTitle: {
marginTop: spacing.xs,
},
actions: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.xl,
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
minHeight: 44,
borderRadius: radius.sm,
backgroundColor: colors.accent,
paddingHorizontal: spacing.lg,
justifyContent: 'center',
},
secondaryButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
minHeight: 44,
borderRadius: radius.sm,
backgroundColor: colors.bgSecondary,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
paddingHorizontal: spacing.lg,
justifyContent: 'center',
},
}));
+284
View File
@@ -0,0 +1,284 @@
import { useRef, useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import * as DocumentPicker from 'expo-document-picker';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { SignalResultCard } from '@/components/signal/SignalResultCard';
import { decodeSignalFromUri } from '@/audio/signalDecodeImage';
import { SIGNAL_SCAN_GUIDE } from '@/audio/signalScanGeometry';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { SignalPayload } from '@boof2015/astra-signal';
export default function SignalScanScreen() {
const styles = useStyles();
const ripple = useRipple();
const colors = useColors();
const router = useRouter();
const cameraRef = useRef<CameraView>(null);
const [permission, requestPermission] = useCameraPermissions();
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<SignalPayload | null>(null);
const [error, setError] = useState<string | null>(null);
const [previewSize, setPreviewSize] = useState({ width: 0, height: 0 });
const runDecode = async (uri: string, useGuideCrop = false) => {
setBusy(true);
setError(null);
try {
setResult(await decodeSignalFromUri(uri, useGuideCrop ? { previewSize } : undefined));
} catch {
setError("Couldn't read Signal. Line the code up in the frame, hold steady, and try again.");
} finally {
setBusy(false);
}
};
const capture = async () => {
if (busy) return;
const photo = await cameraRef.current?.takePictureAsync({ quality: 1 });
if (photo?.uri) await runDecode(photo.uri, true);
};
const pickImage = async () => {
if (busy) return;
const picked = await DocumentPicker.getDocumentAsync({ type: 'image/*', copyToCacheDirectory: true });
const uri = picked.assets?.[0]?.uri;
if (uri) await runDecode(uri);
};
return (
<Screen>
<View style={styles.header}>
<Pressable android_ripple={ripple.bounded} style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Signal
</Text>
</Pressable>
</View>
<Text variant="title" style={styles.heading}>
Scan the Signal
</Text>
{!permission ? (
<View style={styles.center} />
) : !permission.granted ? (
<View style={styles.permissionCard}>
<Ionicons name="camera-outline" size={28} color={colors.accent} />
<Text variant="body">Camera access is needed to scan a Signal.</Text>
<Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={() => void requestPermission()}>
<Text variant="body" color={colors.accentTextStrong}>
Allow camera
</Text>
</Pressable>
<Pressable android_ripple={ripple.bounded} style={styles.linkButton} onPress={() => void pickImage()}>
<Text variant="body" color={colors.accent}>
Or pick a Signal image
</Text>
</Pressable>
</View>
) : (
<View style={styles.scannerFrame}>
<CameraView
ref={cameraRef}
style={styles.camera}
facing="back"
onLayout={(event) => setPreviewSize(event.nativeEvent.layout)}
/>
<View pointerEvents="none" style={styles.scanGuide}>
<View style={[styles.guideCorner, styles.guideTopLeft]} />
<View style={[styles.guideCorner, styles.guideTopRight]} />
<View style={[styles.guideCorner, styles.guideBottomLeft]} />
<View style={[styles.guideCorner, styles.guideBottomRight]} />
</View>
{busy ? (
<View style={styles.busyOverlay}>
<ActivityIndicator color={colors.accent} />
<Text variant="label" color={colors.textPrimary}>
Reading signal
</Text>
</View>
) : null}
<View style={styles.controls}>
<Pressable android_ripple={ripple.icon(28)} style={styles.iconButton} onPress={() => void pickImage()} hitSlop={8}>
<Ionicons name="image-outline" size={24} color={colors.textPrimary} />
</Pressable>
<Pressable android_ripple={ripple.bounded} style={styles.shutter} onPress={() => void capture()} hitSlop={8}>
<Ionicons name="pulse" size={26} color={colors.accentTextStrong} />
</Pressable>
<View style={styles.iconButton} />
</View>
</View>
)}
{error ? (
<View style={styles.errorPanel}>
<Ionicons name="alert-circle-outline" size={20} color={colors.warning} />
<Text variant="body" color={colors.textPrimary} style={styles.errorCopy}>
{error}
</Text>
</View>
) : null}
{result ? (
<View style={styles.resultWrap}>
<SignalResultCard payload={result} />
<Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={() => setResult(null)}>
<Text variant="body" color={colors.accentTextStrong}>
Scan another
</Text>
</Pressable>
</View>
) : null}
</Screen>
);
}
const useStyles = createThemedStyles((colors) => ({
header: {
marginTop: spacing.md,
marginBottom: spacing.lg,
},
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
heading: {
marginBottom: spacing.lg,
},
center: {
flex: 1,
},
permissionCard: {
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
padding: spacing.lg,
gap: spacing.md,
},
primaryButton: {
minHeight: 44,
borderRadius: radius.sm,
backgroundColor: colors.accent,
paddingHorizontal: spacing.lg,
alignItems: 'center',
justifyContent: 'center',
},
linkButton: {
alignItems: 'center',
paddingVertical: spacing.sm,
},
scannerFrame: {
flex: 1,
borderRadius: radius.md,
overflow: 'hidden',
backgroundColor: colors.bgSecondary,
marginBottom: spacing.lg,
},
camera: {
flex: 1,
},
scanGuide: {
position: 'absolute',
left: `${SIGNAL_SCAN_GUIDE.horizontalInset * 100}%`,
right: `${SIGNAL_SCAN_GUIDE.horizontalInset * 100}%`,
top: `${SIGNAL_SCAN_GUIDE.top * 100}%`,
aspectRatio: SIGNAL_SCAN_GUIDE.aspectRatio,
},
guideCorner: {
position: 'absolute',
width: 30,
height: 22,
borderColor: colors.accent,
},
guideTopLeft: {
left: 0,
top: 0,
borderLeftWidth: 3,
borderTopWidth: 3,
borderTopLeftRadius: radius.sm,
},
guideTopRight: {
right: 0,
top: 0,
borderRightWidth: 3,
borderTopWidth: 3,
borderTopRightRadius: radius.sm,
},
guideBottomLeft: {
left: 0,
bottom: 0,
borderLeftWidth: 3,
borderBottomWidth: 3,
borderBottomLeftRadius: radius.sm,
},
guideBottomRight: {
right: 0,
bottom: 0,
borderRightWidth: 3,
borderBottomWidth: 3,
borderBottomRightRadius: radius.sm,
},
busyOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
backgroundColor: 'rgba(0,0,0,0.35)',
},
controls: {
position: 'absolute',
left: spacing.lg,
right: spacing.lg,
bottom: spacing.lg,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
iconButton: {
width: 48,
height: 48,
borderRadius: radius.pill,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgSecondary,
},
shutter: {
width: 64,
height: 64,
borderRadius: radius.pill,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.accent,
},
errorPanel: {
flexDirection: 'row',
gap: spacing.sm,
alignItems: 'center',
borderRadius: radius.md,
backgroundColor: colors.bgSecondary,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
padding: spacing.md,
marginBottom: spacing.md,
},
errorCopy: {
flex: 1,
},
resultWrap: {
gap: spacing.md,
marginBottom: spacing.lg,
},
}));
+104
View File
@@ -0,0 +1,104 @@
import { EncodingType, readAsStringAsync } from 'expo-file-system/legacy';
import { AlphaType, ColorType, Skia, rect, type SkImage } from '@shopify/react-native-skia';
import {
decodeSignalImage,
type SignalDecodeResult,
type SignalPayload,
} from '@boof2015/astra-signal';
import {
signalGuideCaptureSourceRect,
type SignalImageRect,
type SignalImageSize,
} from './signalScanGeometry';
const MAX_DIM = 2048;
export interface DecodeSignalFromUriOptions {
/** Camera preview size used to map the on-screen guide into the full photo. */
previewSize?: SignalImageSize;
}
function fullRect(source: SkImage): SignalImageRect {
return { x: 0, y: 0, width: source.width(), height: source.height() };
}
function decodeRect(source: SkImage, crop: SignalImageRect): SignalDecodeResult {
const scale = Math.min(1, MAX_DIM / Math.max(crop.width, crop.height));
const width = Math.max(1, Math.round(crop.width * scale));
const height = Math.max(1, Math.round(crop.height * scale));
const surface = Skia.Surface.MakeOffscreen(width, height);
if (!surface) throw new Error('Could not process that image.');
const paint = Skia.Paint();
const canvas = surface.getCanvas();
canvas.drawImageRect(
source,
rect(crop.x, crop.y, crop.width, crop.height),
rect(0, 0, width, height),
paint
);
surface.flush();
const snapshot = surface.makeImageSnapshot();
const pixels = snapshot.readPixels(0, 0, {
width,
height,
colorType: ColorType.RGBA_8888,
alphaType: AlphaType.Unpremul,
});
paint.dispose();
snapshot.dispose();
surface.dispose();
if (!pixels) throw new Error('Could not read image pixels.');
return decodeSignalImage({ data: pixels, width, height });
}
function logDiagnostics(result: SignalDecodeResult, source: 'guide' | 'full'): void {
if (!__DEV__) return;
console.debug('[Astra Signal] decoded image', {
source,
tier: result.tier,
correctedBytes: result.correctedBytes,
erasedBytes: result.erasedBytes,
confidence: result.confidence,
});
}
export async function decodeSignalFromUri(
uri: string,
options: DecodeSignalFromUriOptions = {}
): Promise<SignalPayload> {
const b64 = await readAsStringAsync(uri, { encoding: EncodingType.Base64 });
const encoded = Skia.Data.fromBase64(b64);
const source = Skia.Image.MakeImageFromEncoded(encoded);
encoded.dispose();
if (!source) throw new Error('Could not read that image.');
try {
const crop = options.previewSize
? signalGuideCaptureSourceRect(
{ width: source.width(), height: source.height() },
options.previewSize
)
: null;
if (crop) {
try {
const result = decodeRect(source, crop);
logDiagnostics(result, 'guide');
return result.payload;
} catch (error) {
if (__DEV__) console.debug('[Astra Signal] guide decode failed', error);
// The guide crop is a fast, high-resolution first attempt. Full-image
// search below handles framing mismatch and images picked from storage.
}
}
try {
const result = decodeRect(source, fullRect(source));
logDiagnostics(result, 'full');
return result.payload;
} catch (error) {
if (__DEV__) console.debug('[Astra Signal] full-image decode failed', error);
throw error;
}
} finally {
source.dispose();
}
}
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
SIGNAL_SCAN_GUIDE,
signalGuideCapturePreviewRect,
signalGuideCaptureSourceRect,
} from './signalScanGeometry.ts';
test('uses the padded small-tier presentation geometry for the visible guide', () => {
assert.equal(SIGNAL_SCAN_GUIDE.aspectRatio, 2.65);
});
test('the high-resolution crop spans the preview but keeps allocation height bounded', () => {
const preview = { width: 360, height: 640 };
const crop = signalGuideCapturePreviewRect(preview);
assert.ok(crop);
const guideWidth = preview.width * (1 - SIGNAL_SCAN_GUIDE.horizontalInset * 2);
const guideHeight = guideWidth / SIGNAL_SCAN_GUIDE.aspectRatio;
assert.equal(crop.x, 0);
assert.equal(crop.width, preview.width);
assert.ok(crop.width > guideWidth);
assert.ok(crop.height > guideHeight);
assert.ok(crop.height < guideHeight * 1.5);
});
test('maps the expanded preview band into a portrait camera capture', () => {
const crop = signalGuideCaptureSourceRect(
{ width: 3000, height: 4000 },
{ width: 360, height: 640 }
);
assert.ok(crop);
assert.ok(crop.x > 0);
assert.ok(crop.x + crop.width < 3000);
assert.ok(crop.width > 2200);
assert.ok(crop.height > 950);
assert.ok(crop.height < 1200);
});
+53
View File
@@ -0,0 +1,53 @@
export interface SignalImageSize {
width: number;
height: number;
}
export interface SignalImageRect extends SignalImageSize {
x: number;
y: number;
}
// The visible frame describes the common small tier plus the real isolation
// padding used by the mobile presentation, rather than only the black envelope.
export const SIGNAL_SCAN_GUIDE = {
horizontalInset: 0.06,
top: 0.32,
aspectRatio: 2.65,
} as const;
const OPTICAL_SEARCH_ASPECT = 228 / 40;
/**
* The visible guide is not a crop boundary. Capture the entire preview width
* while keeping the vertical band bounded. A large first-pass bitmap can leave
* too much allocation pressure for the full-image fallback on a phone.
*/
export function signalGuideCapturePreviewRect(preview: SignalImageSize): SignalImageRect | null {
if (preview.width <= 0 || preview.height <= 0) return null;
const guideWidth = preview.width * (1 - SIGNAL_SCAN_GUIDE.horizontalInset * 2);
const guideHeight = guideWidth / SIGNAL_SCAN_GUIDE.aspectRatio;
const guideCenterY = preview.height * SIGNAL_SCAN_GUIDE.top + guideHeight / 2;
const searchHeight = (guideWidth / OPTICAL_SEARCH_ASPECT) * 3;
const y0 = Math.max(0, guideCenterY - searchHeight / 2);
const y1 = Math.min(preview.height, guideCenterY + searchHeight / 2);
return { x: 0, y: y0, width: preview.width, height: y1 - y0 };
}
/** Map the expanded visible-preview search band into the captured photo. */
export function signalGuideCaptureSourceRect(
source: SignalImageSize,
preview: SignalImageSize
): SignalImageRect | null {
const capture = signalGuideCapturePreviewRect(preview);
if (!capture || source.width <= 0 || source.height <= 0) return null;
const coverScale = Math.max(preview.width / source.width, preview.height / source.height);
const overflowX = (source.width * coverScale - preview.width) / 2;
const overflowY = (source.height * coverScale - preview.height) / 2;
const x0 = Math.max(0, (capture.x + overflowX) / coverScale);
const y0 = Math.max(0, (capture.y + overflowY) / coverScale);
const x1 = Math.min(source.width, (capture.x + capture.width + overflowX) / coverScale);
const y1 = Math.min(source.height, (capture.y + capture.height + overflowY) / coverScale);
if (x1 - x0 < 2 || y1 - y0 < 2) return null;
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
}
+73
View File
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Track } from '../types/audio.ts';
import {
SIGNAL_LINK_PREFIX,
decodeTrackSignalLink,
encodeTrackSignalLink,
signalInputFromTrack,
signalLayoutFromTrack,
} from './signalShare.ts';
function track(overrides: Partial<Track> = {}): Track {
return {
id: 'signal-test',
path: '/music/replay.flac',
title: 'Replay',
artist: 'ナナツカゼ',
album: 'Signal Tests',
duration: 213.6,
format: 'flac',
...overrides,
};
}
test('converts a Track into rounded, database-free Signal metadata', () => {
assert.deepEqual(signalInputFromTrack(track()), {
artist: 'ナナツカゼ',
title: 'Replay',
durationSec: 214,
});
assert.equal(signalInputFromTrack(track({ duration: Number.NaN })).durationSec, 0);
});
test('creates a v3 connected layout and round-trips a Unicode link', () => {
const source = track();
const layout = signalLayoutFromTrack(source);
assert.equal(layout.version, 3);
assert.equal(layout.columns.length, layout.dataColumns + 8);
const link = encodeTrackSignalLink(source);
assert.ok(link.startsWith(SIGNAL_LINK_PREFIX));
assert.deepEqual(decodeTrackSignalLink(link), {
version: 3,
type: 'metadata',
artist: 'ナナツカゼ',
title: 'Replay',
durationSec: 214,
});
});
test('preserves ASCII case and punctuation in track metadata', () => {
const source = track({ artist: 'N!GHT', title: '#iwannadance', duration: 222 });
const layout = signalLayoutFromTrack(source);
assert.equal(layout.payload.artist, 'N!GHT');
assert.equal(layout.payload.title, '#iwannadance');
assert.deepEqual(decodeTrackSignalLink(encodeTrackSignalLink(source)), {
version: 3,
type: 'metadata',
artist: 'N!GHT',
title: '#iwannadance',
durationSec: 222,
});
});
test('rejects non-v3, malformed, and CRC-corrupted links', () => {
assert.throws(() => decodeTrackSignalLink('astra:signal:v2:AAAA'), /v3 link/);
assert.throws(() => decodeTrackSignalLink(`${SIGNAL_LINK_PREFIX}%`));
const valid = encodeTrackSignalLink(track());
const index = SIGNAL_LINK_PREFIX.length + 5;
const replacement = valid[index] === 'A' ? 'B' : 'A';
const corrupted = `${valid.slice(0, index)}${replacement}${valid.slice(index + 1)}`;
assert.throws(() => decodeTrackSignalLink(corrupted));
});
+35
View File
@@ -0,0 +1,35 @@
import {
SIGNAL_LINK_PREFIX,
decodeSignalLink as decodeLibrarySignalLink,
encodeSignal,
encodeSignalLink as encodeLibrarySignalLink,
type SignalInput,
type SignalLayout,
type SignalPayload,
} from '@boof2015/astra-signal';
import type { Track } from '../types/audio';
export { SIGNAL_LINK_PREFIX };
/** Convert local track metadata into the database-free v3 Signal input. */
export function signalInputFromTrack(track: Track): SignalInput {
return {
artist: track.artist,
title: track.title,
durationSec: Number.isFinite(track.duration) ? Math.round(track.duration) : 0,
};
}
/** Build the ECC-protected optical layout rendered by SignalCode. */
export function signalLayoutFromTrack(track: Track): SignalLayout {
return encodeSignal(signalInputFromTrack(track));
}
/** Build the compact v3 link frame. Links carry CRC, but no visual padding or ECC. */
export function encodeTrackSignalLink(track: Track): string {
return encodeLibrarySignalLink(signalInputFromTrack(track));
}
export function decodeTrackSignalLink(value: string): SignalPayload {
return decodeLibrarySignalLink(value);
}
+29
View File
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Track } from '../types/audio.ts';
import { SIGNAL_LINK_PREFIX, encodeTrackSignalLink } from './signalShare.ts';
import { getSignalShareRedirectPath } from './signalShareIntent.ts';
const source: Track = {
id: 'intent-test',
path: '/music/replay.flac',
title: 'Replay',
artist: 'ナナツカゼ',
album: 'Signal Tests',
duration: 214,
format: 'flac',
};
test('routes native and percent-encoded Astra Signal v3 links', () => {
const link = encodeTrackSignalLink(source);
const payload = link.slice(SIGNAL_LINK_PREFIX.length);
assert.equal(getSignalShareRedirectPath(link), `/signal/import?data=${payload}`);
assert.equal(getSignalShareRedirectPath(encodeURIComponent(link)), `/signal/import?data=${payload}`);
assert.equal(getSignalShareRedirectPath(`astra://signal:v3:${payload}`), `/signal/import?data=${payload}`);
});
test('ignores unrelated, v2, and empty Signal intents', () => {
assert.equal(getSignalShareRedirectPath('https://example.com'), null);
assert.equal(getSignalShareRedirectPath('astra:signal:v2:AAAA'), null);
assert.equal(getSignalShareRedirectPath('astra:signal:v3:'), null);
});
+21
View File
@@ -0,0 +1,21 @@
import { SIGNAL_LINK_PREFIX } from './signalShare.ts';
// Match the scheme-less marker because Android/Expo can normalize opaque
// `astra:` URIs before redirectSystemPath receives them.
const SIGNAL_MARKER = SIGNAL_LINK_PREFIX.replace(/^astra:/, '');
/** Return the import route for an Astra Signal v3 deep link, or null. */
export function getSignalShareRedirectPath(path: string): string | null {
let candidate = path.trim();
try {
candidate = decodeURIComponent(candidate);
} catch {
// Malformed percent-encoding cannot be a valid frame, but matching the raw
// string keeps this router helper side-effect free.
}
const idx = candidate.indexOf(SIGNAL_MARKER);
if (idx === -1) return null;
const payload = candidate.slice(idx + SIGNAL_MARKER.length).match(/^[A-Za-z0-9_-]+/)?.[0];
if (!payload) return null;
return `/signal/import?data=${payload}`;
}
@@ -363,6 +363,18 @@ export function NowPlayingOverlay() {
},
});
}
if (!isDesktopTarget) {
menuItems.push({
key: 'share-signal',
label: 'Share as Signal',
icon: 'pulse-outline',
onPress: () => {
closeMenu();
dismissSheet();
router.navigate('/signal' as never);
},
});
}
// The overlay stays mounted; open/close is this one shared value sliding the
// sheet on the UI thread. Starts off-screen so a pre-warmed mount never flashes.
+98
View File
@@ -0,0 +1,98 @@
import { forwardRef, useImperativeHandle, useMemo } from 'react';
import { AlphaType, Canvas, ColorType, Path, Rect, Skia } from '@shopify/react-native-skia';
import {
SIGNAL_SPEC,
levelHeightModules,
rasterizeSignal,
type SignalLayout,
} from '@boof2015/astra-signal';
export interface SignalCodeHandle {
/** Canonical six-pixels-per-module PNG as base64, independent of display size. */
snapshot: () => string | null;
}
interface SignalCodeProps {
layout: SignalLayout;
width: number;
foreground: string;
background: string;
exportForeground?: string;
exportBackground?: string;
}
const G = SIGNAL_SPEC.geom;
function rgb(hex: string): [number, number, number] {
const value = hex.replace(/^#/, '');
if (!/^[0-9a-fA-F]{6}$/.test(value)) throw new Error('Signal colors must be six-digit hex values');
return [
Number.parseInt(value.slice(0, 2), 16),
Number.parseInt(value.slice(2, 4), 16),
Number.parseInt(value.slice(4, 6), 16),
];
}
/** Render the v3 connected upper/lower spectrum envelope directly from SignalLayout. */
export const SignalCode = forwardRef<SignalCodeHandle, SignalCodeProps>(function SignalCode(
{ layout, width, foreground, background, exportForeground = foreground, exportBackground = background },
ref
) {
const scale = width / layout.widthModules;
const height = layout.heightModules * scale;
useImperativeHandle(
ref,
() => ({
snapshot: () => {
const raster = rasterizeSignal(layout, {
scale: 6,
foreground: rgb(exportForeground),
background: rgb(exportBackground),
});
const bytes = new Uint8Array(raster.data.length);
bytes.set(raster.data);
const data = Skia.Data.fromBytes(bytes);
const image = Skia.Image.MakeImage(
{
width: raster.width,
height: raster.height,
colorType: ColorType.RGBA_8888,
alphaType: AlphaType.Unpremul,
},
data,
raster.width * 4
);
data.dispose();
if (!image) return null;
try {
return image.encodeToBase64();
} finally {
image.dispose();
}
},
}),
[exportBackground, exportForeground, layout]
);
const envelope = useMemo(() => {
const path = Skia.Path.Make();
const centerY = (G.quietModules + G.halfHeightModules) * scale;
for (let index = 0; index < layout.columns.length; index += 1) {
const column = layout.columns[index];
if (!column) continue;
const upper = levelHeightModules(column.upperLevel) * scale;
const lower = levelHeightModules(column.lowerLevel) * scale;
const x = (G.quietModules + index * G.columnPitchModules) * scale;
path.addRect(Skia.XYWHRect(x, centerY - upper, G.columnPitchModules * scale, upper + lower));
}
return path;
}, [layout, scale]);
return (
<Canvas style={{ width, height }}>
<Rect x={0} y={0} width={width} height={height} color={background} />
<Path path={envelope} color={foreground} />
</Canvas>
);
});
@@ -0,0 +1,75 @@
import { StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { formatDuration } from '@/lib/format';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import type { SignalPayload } from '@boof2015/astra-signal';
/**
* Shows the song a scanned/imported Signal decoded to. Resolving this to a
* playable track (local library match, then online lookup) is the next phase;
* for now it confirms the round-trip — the make-or-break for the format.
*/
export function SignalResultCard({ payload }: { payload: SignalPayload }) {
const styles = useStyles();
const colors = useColors();
const title = payload.title.trim();
const artist = payload.artist.trim();
return (
<View style={styles.card}>
<View style={styles.badge}>
<Ionicons name="pulse" size={18} color={colors.accentTextStrong} />
<Text variant="label" color={colors.accentTextStrong}>
Signal found
</Text>
</View>
<Text variant="title" style={styles.title}>
{title || 'Unknown title'}
</Text>
<Text variant="body" color={colors.textSecondary}>
{artist || 'Unknown artist'}
</Text>
{payload.durationSec > 0 ? (
<View style={styles.metaRow}>
<Ionicons name="time-outline" size={15} color={colors.textTertiary} />
<Text variant="mono" color={colors.textTertiary}>
{formatDuration(payload.durationSec)}
</Text>
</View>
) : null}
</View>
);
}
const useStyles = createThemedStyles((colors) => ({
card: {
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
padding: spacing.lg,
gap: spacing.xs,
},
badge: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
alignSelf: 'flex-start',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.pill,
backgroundColor: colors.accentGlow,
marginBottom: spacing.sm,
},
title: {
marginTop: spacing.xs,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginTop: spacing.sm,
},
}));